Compare commits
158
Commits
89856eb7c3
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f966470d33 | ||
|
|
c76ede2ab4 | ||
|
|
2fd043b634 | ||
|
|
fb6bbe9145 | ||
|
|
d1585d7483 | ||
|
|
13d853217d | ||
|
|
cc27d57e4a | ||
|
|
f5c5ea5a0b | ||
|
|
f1baea1705 | ||
|
|
31f7d39647 | ||
|
|
c5a834bfd2 | ||
|
|
1eef9b75ee | ||
|
|
ba9c885f52 | ||
|
|
88683a8d8f | ||
|
|
0c48c5dee3 | ||
|
|
101a0acb6b | ||
|
|
2d1956b653 | ||
|
|
b7bba8b53a | ||
|
|
7b25b767f8 | ||
|
|
4631b95144 | ||
|
|
38627c498b | ||
|
|
282a8d31b5 | ||
|
|
ab4fb4c1ee | ||
|
|
e3e9e83bc1 | ||
|
|
4269ebec04 | ||
|
|
e8b9adcde4 | ||
|
|
668a9062b3 | ||
|
|
5fd2ccf084 | ||
|
|
5686bbc9fd | ||
|
|
2cd57a32b2 | ||
|
|
89f4f99622 | ||
|
|
78d571ed14 | ||
|
|
e5332f4a7f | ||
|
|
3ed1545c3c | ||
|
|
9da20d15da | ||
|
|
052d60bd7d | ||
|
|
2456d6fda5 | ||
|
|
e7803d1aba | ||
|
|
ca5fddf89b | ||
|
|
88fad3893e | ||
|
|
82f9b0e48c | ||
|
|
ddb4c1454d | ||
|
|
7b1cf854f2 | ||
|
|
51c6d7f835 | ||
|
|
9d55ce0a87 | ||
|
|
0f8d61188a | ||
|
|
7363dffb9d | ||
|
|
8e4b7deaa4 | ||
|
|
ec845cbc25 | ||
|
|
e7079e223f | ||
|
|
dea5bd581d | ||
|
|
75b85b46d1 | ||
|
|
68f00bc948 | ||
|
|
5e9f7a7dc3 | ||
|
|
b038f022d3 | ||
|
|
cf7515fb35 | ||
|
|
bb4c1dfe4f | ||
|
|
72b56964c3 | ||
|
|
5b0a6691f8 | ||
|
|
1239c638a5 | ||
|
|
d2fa0787d8 | ||
|
|
a7056702e8 | ||
|
|
bb56283063 | ||
|
|
130ef1f0fe | ||
|
|
724205b1df | ||
|
|
69824ea45d | ||
|
|
15bc299987 | ||
|
|
87bdb0c6ed | ||
|
|
aa96bbedbc | ||
|
|
4df277c81f | ||
|
|
12646b6ca0 | ||
|
|
1e674d70c2 | ||
|
|
5ee77698db | ||
|
|
d1f5661881 | ||
|
|
532d078720 | ||
|
|
27e5df106f | ||
|
|
33d98868c3 | ||
|
|
fb13e53cb5 | ||
|
|
60a5495ccd | ||
|
|
f1dc90621c | ||
|
|
eecb116709 | ||
|
|
783d25b1c4 | ||
|
|
af06eecfd0 | ||
|
|
9bd08a3a5b | ||
|
|
3d66247e11 | ||
|
|
4390554477 | ||
|
|
74457db4eb | ||
|
|
5d61da481b | ||
|
|
42d109cae3 | ||
|
|
9b48b1ff5d | ||
|
|
eac4a0c071 | ||
|
|
e62c7cf4f5 | ||
|
|
0245980ea5 | ||
|
|
68b1aa64e9 |
@@ -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
+50
-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"
|
||||
@@ -4536,9 +4240,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 +4472,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 +4781,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.29.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5257,6 +4956,16 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-serde"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
@@ -5267,12 +4976,15 @@ dependencies = [
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5382,6 +5094,8 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -5683,27 +5397,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]]
|
||||
@@ -5714,8 +5408,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder 0.244.0",
|
||||
"wasmparser 0.244.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5743,286 +5437,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"
|
||||
@@ -6120,6 +5534,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"
|
||||
@@ -6229,25 +5661,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"
|
||||
@@ -6511,7 +5924,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser 0.244.0",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6558,10 +5971,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]]
|
||||
@@ -6579,26 +5992,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]]
|
||||
@@ -6650,6 +6044,7 @@ dependencies = [
|
||||
"serial_test",
|
||||
"session-metrics",
|
||||
"session-store",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
@@ -6660,11 +6055,8 @@ dependencies = [
|
||||
"tracing",
|
||||
"tungstenite 0.28.0",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
"wat",
|
||||
"workdir",
|
||||
"workspace-api",
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6694,6 +6086,8 @@ dependencies = [
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"workdir",
|
||||
@@ -6782,6 +6176,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml",
|
||||
"tui",
|
||||
"uuid",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
]
|
||||
@@ -6839,6 +6234,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ts-rs",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -132,6 +132,7 @@ tokio-tungstenite = "0.29"
|
||||
tower = "0.5"
|
||||
toml = "1.1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
url = "2.5"
|
||||
uuid = "1.23"
|
||||
zeroize = "1"
|
||||
|
||||
@@ -40,7 +40,7 @@ use tracing_subscriber::EnvFilter;
|
||||
|
||||
use agen::{
|
||||
Engine, EngineRunExit, RunInterruptionReason,
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ToolResultPrinterPolicy {
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
let name = self
|
||||
.call_names
|
||||
.lock()
|
||||
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
|
||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||
}
|
||||
|
||||
PostToolAction::Continue
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+336
-106
@@ -15,8 +15,12 @@ use crate::{
|
||||
},
|
||||
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
|
||||
interceptor::{
|
||||
DefaultInterceptor, Interceptor, PostToolAction, PreRequestAction, PreToolAction,
|
||||
PromptAction, ToolCallInfo, ToolResultInfo, TurnEndAction,
|
||||
AssistantTurnEndContext, DefaultInterceptor, Interceptor, InterceptorCallId,
|
||||
InterceptorCounter, InterceptorCounters, InterceptorError, InterceptorErrorCategory,
|
||||
InterceptorFailure, InterceptorInvocation, InterceptorPhase, InterceptorRunId,
|
||||
InterceptorTurnId, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
|
||||
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, RunExitContext,
|
||||
ToolCallInfo, ToolResultInfo, TurnEndAction,
|
||||
},
|
||||
llm_client::{
|
||||
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream,
|
||||
@@ -58,6 +62,9 @@ pub enum EngineError {
|
||||
/// A durable-history observer rejected an item before it entered history.
|
||||
#[error("History append failed: {0}")]
|
||||
HistoryAppend(String),
|
||||
/// A trusted host interceptor callback failed.
|
||||
#[error(transparent)]
|
||||
Interceptor(#[from] InterceptorFailure),
|
||||
/// Tool terminalization lost its execution-attempt compare-and-set fence.
|
||||
#[error("Tool execution attempt fence failed: {0}")]
|
||||
ToolAttemptFence(String),
|
||||
@@ -181,7 +188,7 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
|
||||
/// Result of [`Engine::run`] or [`Engine::resume`].
|
||||
///
|
||||
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
|
||||
pub struct EngineRunOutput<C: LlmClient, A = ()> {
|
||||
pub struct EngineRunOutput<C: LlmClient, A: Send + Sync = ()> {
|
||||
/// The Engine, now in Locked state.
|
||||
pub engine: Engine<C, Locked, A>,
|
||||
/// Outcome of the turn.
|
||||
@@ -305,7 +312,7 @@ enum StreamCompletion {
|
||||
Interrupted { reason: String },
|
||||
}
|
||||
|
||||
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
||||
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
|
||||
/// LLM client
|
||||
client: C,
|
||||
/// Retry policy for opening an LLM response stream.
|
||||
@@ -322,7 +329,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
||||
/// Tool server handle
|
||||
tool_server: ToolServerHandle,
|
||||
/// Interceptor for control-flow decisions
|
||||
interceptor: Box<dyn Interceptor>,
|
||||
interceptor: Box<dyn Interceptor<A>>,
|
||||
/// System prompt
|
||||
system_prompt: Option<String>,
|
||||
/// History length at lock time (only meaningful in Locked state)
|
||||
@@ -341,6 +348,11 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
||||
/// `max_turns` is enforced against this run-scoped count rather than the
|
||||
/// cumulative `turn_count` above.
|
||||
active_run_turn_count: Option<usize>,
|
||||
/// Identity retained across pause/yield and resume.
|
||||
active_run_id: Option<InterceptorRunId>,
|
||||
next_run_id: u64,
|
||||
interceptor_invocation_count: usize,
|
||||
last_run_exit_observer_failure: Option<InterceptorFailure>,
|
||||
/// LlmCall count (per-Engine running counter, monotonic). Unlike
|
||||
/// `turn_count` this never collapses retries.
|
||||
llm_call_count: usize,
|
||||
@@ -421,21 +433,57 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
|
||||
_state: PhantomData<(S, A)>,
|
||||
}
|
||||
|
||||
impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
fn start_logical_run(&mut self) {
|
||||
self.active_run_turn_count = Some(0);
|
||||
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
|
||||
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
|
||||
self.interceptor_invocation_count = 0;
|
||||
self.last_run_exit_observer_failure = None;
|
||||
}
|
||||
|
||||
fn ensure_logical_run(&mut self) {
|
||||
self.active_run_turn_count.get_or_insert(0);
|
||||
if self.active_run_id.is_none() {
|
||||
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
|
||||
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
|
||||
self.interceptor_invocation_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
|
||||
if !matches!(
|
||||
result,
|
||||
Ok(EngineResult::Paused | EngineResult::Yielded) | Err(EngineError::PauseRequested)
|
||||
) {
|
||||
fn interceptor_invocation(
|
||||
&mut self,
|
||||
phase: InterceptorPhase,
|
||||
turn_id: Option<usize>,
|
||||
call_id: Option<InterceptorCallId>,
|
||||
tool_call: usize,
|
||||
) -> InterceptorInvocation {
|
||||
let invocation = self.interceptor_invocation_count;
|
||||
self.interceptor_invocation_count = self.interceptor_invocation_count.saturating_add(1);
|
||||
InterceptorInvocation {
|
||||
run_id: self
|
||||
.active_run_id
|
||||
.expect("logical run identity must exist before interception"),
|
||||
turn_id: turn_id.map(|value| InterceptorTurnId(value as u64)),
|
||||
call_id,
|
||||
phase,
|
||||
counters: InterceptorCounters {
|
||||
invocation: InterceptorCounter::from_usize(invocation),
|
||||
engine_turn: InterceptorCounter::from_usize(self.turn_count),
|
||||
run_turn: InterceptorCounter::from_usize(
|
||||
self.active_run_turn_count.unwrap_or_default(),
|
||||
),
|
||||
llm_call: InterceptorCounter::from_usize(self.llm_call_count),
|
||||
tool_batch: InterceptorCounter::from_usize(self.tool_execution_batch_count),
|
||||
tool_call: InterceptorCounter::from_usize(tool_call),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_logical_run(&mut self, exit: &EngineRunExit) {
|
||||
if !matches!(exit, EngineRunExit::Paused | EngineRunExit::Yielded) {
|
||||
self.active_run_turn_count = None;
|
||||
self.active_run_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,7 +789,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
/// The interceptor governs approval, skip, pause, and abort decisions
|
||||
/// at key points in the execution loop. If not set, the default
|
||||
/// interceptor is used (all Continue / Finish).
|
||||
pub fn set_interceptor(&mut self, interceptor: impl Interceptor + 'static) {
|
||||
pub fn set_interceptor(&mut self, interceptor: impl Interceptor<A> + 'static) {
|
||||
self.interceptor = Box::new(interceptor);
|
||||
}
|
||||
|
||||
@@ -842,6 +890,10 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
///
|
||||
/// `Some` is retained only while Pause or Yield permits a later
|
||||
/// [`resume`](Self::resume). Terminal outcomes return this to `None`.
|
||||
pub fn last_run_exit_observer_failure(&self) -> Option<&InterceptorFailure> {
|
||||
self.last_run_exit_observer_failure.as_ref()
|
||||
}
|
||||
|
||||
pub fn active_run_turn_count(&self) -> Option<usize> {
|
||||
self.active_run_turn_count
|
||||
}
|
||||
@@ -853,6 +905,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
/// [`resume`](Self::resume) starts a fresh budget.
|
||||
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
|
||||
self.active_run_turn_count = turn_count;
|
||||
if turn_count.is_none() {
|
||||
self.active_run_id = None;
|
||||
} else if self.active_run_id.is_none() {
|
||||
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
|
||||
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
|
||||
self.interceptor_invocation_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current LlmCall count (per-Engine running counter, never
|
||||
@@ -1078,24 +1137,28 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
request
|
||||
}
|
||||
|
||||
/// Hooks: on_prompt_submit
|
||||
///
|
||||
async fn finalize_interruption<T>(
|
||||
async fn finalize_run_exit(
|
||||
&mut self,
|
||||
result: Result<T, EngineError>,
|
||||
) -> Result<T, EngineError> {
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
let reason = match &err {
|
||||
EngineError::Aborted(reason) => reason.clone(),
|
||||
EngineError::Cancelled => "Cancelled".to_string(),
|
||||
_ => err.to_string(),
|
||||
};
|
||||
self.interceptor.on_abort(&reason).await;
|
||||
Err(err)
|
||||
}
|
||||
history: &History<A>,
|
||||
result: Result<EngineResult, EngineError>,
|
||||
) -> EngineRunExit {
|
||||
let exit = EngineRunExit::from(result);
|
||||
let invocation = self.interceptor_invocation(InterceptorPhase::RunExit, None, None, 0);
|
||||
self.last_run_exit_observer_failure = None;
|
||||
if let Err(error) = self
|
||||
.interceptor
|
||||
.on_run_exit(RunExitContext {
|
||||
invocation,
|
||||
exit: &exit,
|
||||
history: history.entries(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
self.last_run_exit_observer_failure =
|
||||
Some(InterceptorFailure::new(InterceptorPhase::RunExit, error));
|
||||
}
|
||||
self.finish_logical_run(&exit);
|
||||
exit
|
||||
}
|
||||
|
||||
/// Check for pending tool calls (for resuming from Pause)
|
||||
@@ -1166,21 +1229,60 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
||||
let mut approved_calls = Vec::new();
|
||||
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
|
||||
let expected_tool_use_id = tool_call.id.clone();
|
||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
|
||||
let invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PreToolCall,
|
||||
Some(self.turn_count.saturating_sub(1)),
|
||||
Some(InterceptorCallId::Tool(expected_tool_use_id.clone())),
|
||||
call_index,
|
||||
);
|
||||
let mut info = ToolCallInfo {
|
||||
invocation,
|
||||
history: history.entries(),
|
||||
call: tool_call.clone(),
|
||||
meta,
|
||||
tool,
|
||||
context,
|
||||
};
|
||||
|
||||
match self.interceptor.pre_tool_call(&mut info).await {
|
||||
let pre_tool_action =
|
||||
self.interceptor
|
||||
.pre_tool_call(&mut info)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"pre-tool interceptor changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
match pre_tool_action {
|
||||
PreToolAction::Continue => {}
|
||||
PreToolAction::Skip => {
|
||||
continue;
|
||||
}
|
||||
PreToolAction::SyntheticResult(result) => {
|
||||
if result.tool_use_id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"synthetic tool result changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let tool_call = info.call;
|
||||
let mut context = info.context;
|
||||
context.call_id = tool_call.id.clone();
|
||||
@@ -1287,20 +1389,31 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
let mut terminal_call_ids = HashSet::new();
|
||||
let mut pause_requested = false;
|
||||
let mut pause_deadline = None;
|
||||
let mut batch_error = None;
|
||||
let mut locally_enqueued_cancel = false;
|
||||
for result in synthetic_results {
|
||||
self.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
)
|
||||
.await?;
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
&& batch_error.is_none()
|
||||
{
|
||||
batch_error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
let mut futures = futures;
|
||||
if batch_error.is_some() && !futures.is_empty() {
|
||||
let _ = self.cancel_tx.try_send(());
|
||||
locally_enqueued_cancel = true;
|
||||
}
|
||||
while !futures.is_empty() {
|
||||
tokio::select! {
|
||||
// If cancellation and a completed result are both ready, drain
|
||||
@@ -1310,7 +1423,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
result = futures.next() => {
|
||||
let (attempt_id, result) =
|
||||
result.expect("non-empty FuturesUnordered returns a result");
|
||||
self.finalize_and_commit_tool_result(
|
||||
if let Err(error) = self.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
@@ -1318,7 +1431,15 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
).await?;
|
||||
).await {
|
||||
if batch_error.is_none() {
|
||||
batch_error = Some(error);
|
||||
}
|
||||
if !futures.is_empty() {
|
||||
let _ = self.cancel_tx.try_send(());
|
||||
locally_enqueued_cancel = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
pause = self.pause_rx.recv(), if !pause_requested => {
|
||||
if pause.is_some() {
|
||||
@@ -1335,6 +1456,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
||||
pause_deadline = None;
|
||||
let _ = self.cancel_tx.try_send(());
|
||||
locally_enqueued_cancel = true;
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
@@ -1380,7 +1502,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
result = futures.next() => {
|
||||
let (attempt_id, result) =
|
||||
result.expect("non-empty FuturesUnordered returns a result");
|
||||
self.finalize_and_commit_tool_result(
|
||||
if let Err(error) = self.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
@@ -1388,7 +1510,11 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
).await?;
|
||||
).await
|
||||
&& batch_error.is_none()
|
||||
{
|
||||
batch_error = Some(error);
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
}
|
||||
@@ -1402,7 +1528,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
if let Some(handle) = execution_handles.get(call_id) {
|
||||
handle.force_close();
|
||||
}
|
||||
self.finalize_and_commit_tool_result(
|
||||
if let Err(error) = self.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
@@ -1410,11 +1536,18 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
).await?;
|
||||
).await
|
||||
&& batch_error.is_none()
|
||||
{
|
||||
batch_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.timeline.abort_current_block();
|
||||
if let Some(error) = batch_error.take() {
|
||||
return Err(error);
|
||||
}
|
||||
if pause_requested {
|
||||
return Ok(ToolExecutionResult::Paused);
|
||||
}
|
||||
@@ -1423,6 +1556,16 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
// A result-biased ready sibling can empty the batch before the local
|
||||
// cancel signal is selected. Never let that current-batch signal leak
|
||||
// into the next run or resume call.
|
||||
if locally_enqueued_cancel {
|
||||
let _ = self.cancel_rx.try_recv();
|
||||
}
|
||||
if let Some(error) = batch_error {
|
||||
self.timeline.abort_current_block();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(if pause_requested {
|
||||
ToolExecutionResult::Paused
|
||||
} else {
|
||||
@@ -1466,31 +1609,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
}
|
||||
|
||||
let call_info = call_info_map.get(&tool_result.tool_use_id);
|
||||
let mut abort_reason = None;
|
||||
if let Some((tool_call, meta, tool, context)) = call_info {
|
||||
let mut info = ToolResultInfo {
|
||||
call: tool_call.clone(),
|
||||
result: tool_result,
|
||||
meta: meta.clone(),
|
||||
tool: tool.clone(),
|
||||
context: context.clone(),
|
||||
};
|
||||
|
||||
match self.interceptor.post_tool_call(&mut info).await {
|
||||
PostToolAction::Continue => {}
|
||||
PostToolAction::Abort(reason) => {
|
||||
abort_reason = Some(reason);
|
||||
}
|
||||
}
|
||||
tool_result = info.result;
|
||||
}
|
||||
if tool_result.is_error && tool_result.disposition.is_success() {
|
||||
tool_result.disposition = ToolResultDisposition::Error;
|
||||
}
|
||||
tool_result.is_error = !tool_result.disposition.is_success();
|
||||
|
||||
// Cap content only after post_tool_call so interceptors still observe
|
||||
// the full payload and any content they inject is bounded too.
|
||||
// Bound the terminal payload before committing it so the post-tool
|
||||
// interceptor observes exactly the model-visible durable result.
|
||||
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
|
||||
self.tool_output_limits.as_ref(),
|
||||
call_info,
|
||||
@@ -1543,9 +1668,38 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
"Tool execution terminalized"
|
||||
);
|
||||
self.emit_tool_result(&tool_result);
|
||||
if let Some(reason) = abort_reason {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
|
||||
if let Some((tool_call, meta, tool, context)) = call_info {
|
||||
let invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PostToolCall,
|
||||
Some(self.turn_count.saturating_sub(1)),
|
||||
Some(InterceptorCallId::Tool(tool_call.id.clone())),
|
||||
context.call_index,
|
||||
);
|
||||
let info = ToolResultInfo {
|
||||
invocation,
|
||||
history: history.entries(),
|
||||
call: tool_call.clone(),
|
||||
result: tool_result,
|
||||
meta: meta.clone(),
|
||||
tool: tool.clone(),
|
||||
context: context.clone(),
|
||||
};
|
||||
let post_tool_action =
|
||||
self.interceptor
|
||||
.post_tool_call(&info)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PostToolCall,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
if let PostToolAction::Abort(reason) = post_tool_action {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -1608,11 +1762,25 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
// These are committed *before* the per-request clone so they
|
||||
// participate in the LLM request below and get persisted by
|
||||
// the caller that owns durable history.
|
||||
let pending_invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PendingHistoryAppends,
|
||||
Some(current_turn),
|
||||
None,
|
||||
0,
|
||||
);
|
||||
let pending = self
|
||||
.interceptor
|
||||
.pending_history_appends()
|
||||
.pending_history_appends(PendingHistoryAppendsContext {
|
||||
invocation: pending_invocation,
|
||||
history: history.entries(),
|
||||
})
|
||||
.await
|
||||
.map_err(EngineError::HistoryAppend)?;
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PendingHistoryAppends,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
if !pending.is_empty() {
|
||||
self.append_history_items(history, pending, annotate)?;
|
||||
}
|
||||
@@ -1679,7 +1847,27 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
}
|
||||
|
||||
// Interceptor: pre_llm_request
|
||||
match self.interceptor.pre_llm_request(&mut request_context).await {
|
||||
let request_invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PreLlmRequest,
|
||||
Some(current_turn),
|
||||
Some(InterceptorCallId::Llm(self.llm_call_count as u64)),
|
||||
0,
|
||||
);
|
||||
let pre_request_action = self
|
||||
.interceptor
|
||||
.pre_llm_request(PreLlmRequestContext {
|
||||
invocation: request_invocation,
|
||||
items: &mut request_context,
|
||||
history: history.entries(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreLlmRequest,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
match pre_request_action {
|
||||
PreRequestAction::Cancel(reason) => {
|
||||
info!(reason = %reason, "Aborted by interceptor");
|
||||
for cb in &self.turn_end_cbs {
|
||||
@@ -1791,21 +1979,45 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
let tool_calls = self.tool_call_collector.take_collected();
|
||||
let assistant_items =
|
||||
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
|
||||
let assistant_start = history.len();
|
||||
self.append_history_items(history, assistant_items, annotate)?;
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
let turn_end_context = history.items_cloned();
|
||||
match self.interceptor.on_turn_end(&turn_end_context).await {
|
||||
TurnEndAction::Finish => {
|
||||
return Ok(EngineResult::Finished);
|
||||
}
|
||||
TurnEndAction::ContinueWithMessages(additional) => {
|
||||
self.append_history_items(history, additional, annotate)?;
|
||||
let assistant_invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
Some(current_turn),
|
||||
Some(InterceptorCallId::Llm(
|
||||
self.llm_call_count.saturating_sub(1) as u64,
|
||||
)),
|
||||
0,
|
||||
);
|
||||
let assistant_turn_action = self
|
||||
.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
match assistant_turn_action {
|
||||
TurnEndAction::Finish if tool_calls.is_empty() => {
|
||||
return Ok(EngineResult::Finished);
|
||||
}
|
||||
TurnEndAction::Finish => {}
|
||||
TurnEndAction::ContinueWithMessages(additional) => {
|
||||
self.append_history_items(history, additional, annotate)?;
|
||||
if tool_calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
TurnEndAction::Pause => {
|
||||
return Ok(EngineResult::Paused);
|
||||
}
|
||||
}
|
||||
TurnEndAction::Pause => {
|
||||
return Ok(EngineResult::Paused);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2098,7 +2310,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
|
||||
/// Create a new annotated Engine (in Mutable state).
|
||||
pub fn new_annotated(client: C) -> Self {
|
||||
let text_block_collector = TextBlockCollector::new();
|
||||
@@ -2126,6 +2338,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
locked_prefix_len: 0,
|
||||
turn_count: 0,
|
||||
active_run_turn_count: None,
|
||||
active_run_id: None,
|
||||
next_run_id: 1,
|
||||
interceptor_invocation_count: 0,
|
||||
last_run_exit_observer_failure: None,
|
||||
llm_call_count: 0,
|
||||
tool_execution_batch_count: 0,
|
||||
max_turns: None,
|
||||
@@ -2401,6 +2617,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
|
||||
locked_prefix_len,
|
||||
turn_count: self.turn_count,
|
||||
active_run_turn_count: self.active_run_turn_count,
|
||||
active_run_id: self.active_run_id,
|
||||
next_run_id: self.next_run_id,
|
||||
interceptor_invocation_count: self.interceptor_invocation_count,
|
||||
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
|
||||
llm_call_count: self.llm_call_count,
|
||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||
max_turns: self.max_turns,
|
||||
@@ -2477,7 +2697,7 @@ impl<C: LlmClient> Engine<C, Mutable, ()> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
|
||||
/// Execute a turn
|
||||
///
|
||||
/// Adds a new user message to history and sends a request to the LLM.
|
||||
@@ -2488,9 +2708,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
user_input: impl Into<String>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> EngineRunExit {
|
||||
self.run_result_with_annotation(history, user_input.into(), annotate)
|
||||
.await
|
||||
.into()
|
||||
let result = self
|
||||
.run_result_with_annotation(history, user_input.into(), annotate)
|
||||
.await;
|
||||
self.finalize_run_exit(history, result).await
|
||||
}
|
||||
|
||||
async fn run_result_with_annotation(
|
||||
@@ -2501,13 +2722,26 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
) -> Result<EngineResult, EngineError> {
|
||||
// Supplying new user input abandons any paused/yielded logical run.
|
||||
self.active_run_turn_count = None;
|
||||
self.active_run_id = None;
|
||||
self.start_logical_run();
|
||||
let mut user_item = Item::user_message(user_input);
|
||||
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await {
|
||||
PromptAction::Cancel(reason) => {
|
||||
return self
|
||||
.finalize_interruption(Err(EngineError::Aborted(reason)))
|
||||
.await;
|
||||
}
|
||||
let invocation = self.interceptor_invocation(InterceptorPhase::PromptSubmit, None, None, 0);
|
||||
let prompt_action = self
|
||||
.interceptor
|
||||
.on_prompt_submit(PromptSubmitContext {
|
||||
invocation,
|
||||
item: &mut user_item,
|
||||
history: history.entries(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PromptSubmit,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
let extras = match prompt_action {
|
||||
PromptAction::Cancel(reason) => return Err(EngineError::Aborted(reason)),
|
||||
PromptAction::Continue => Vec::new(),
|
||||
PromptAction::ContinueWith(items) => items,
|
||||
};
|
||||
@@ -2515,14 +2749,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
if !extras.is_empty() {
|
||||
self.append_history_items(history, extras, annotate)?;
|
||||
}
|
||||
self.start_logical_run();
|
||||
let result = match self.run_turn_loop(history, annotate).await {
|
||||
match self.run_turn_loop(history, annotate).await {
|
||||
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
||||
other => other,
|
||||
};
|
||||
let result = self.finalize_interruption(result).await;
|
||||
self.finish_logical_run(&result);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume execution (from Paused state).
|
||||
@@ -2531,9 +2761,8 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
history: &mut History<A>,
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> EngineRunExit {
|
||||
self.resume_result_with_annotation(history, annotate)
|
||||
.await
|
||||
.into()
|
||||
let result = self.resume_result_with_annotation(history, annotate).await;
|
||||
self.finalize_run_exit(history, result).await
|
||||
}
|
||||
|
||||
async fn resume_result_with_annotation(
|
||||
@@ -2542,13 +2771,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||
) -> Result<EngineResult, EngineError> {
|
||||
self.ensure_logical_run();
|
||||
let result = match self.run_turn_loop(history, annotate).await {
|
||||
match self.run_turn_loop(history, annotate).await {
|
||||
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
|
||||
other => other,
|
||||
};
|
||||
let result = self.finalize_interruption(result).await;
|
||||
self.finish_logical_run(&result);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the prefix length at lock time
|
||||
@@ -2574,6 +2800,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
|
||||
locked_prefix_len: 0,
|
||||
turn_count: self.turn_count,
|
||||
active_run_turn_count: self.active_run_turn_count,
|
||||
active_run_id: self.active_run_id,
|
||||
next_run_id: self.next_run_id,
|
||||
interceptor_invocation_count: self.interceptor_invocation_count,
|
||||
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
|
||||
llm_call_count: self.llm_call_count,
|
||||
tool_execution_batch_count: self.tool_execution_batch_count,
|
||||
max_turns: self.max_turns,
|
||||
|
||||
+250
-28
@@ -9,8 +9,202 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::Item;
|
||||
use crate::engine::EngineRunExit;
|
||||
use crate::history::HistoryEntry;
|
||||
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
||||
|
||||
// =============================================================================
|
||||
// Typed lifecycle metadata and failures
|
||||
// =============================================================================
|
||||
|
||||
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
|
||||
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
|
||||
|
||||
/// Stable category for the source of an interceptor failure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InterceptorErrorCategory {
|
||||
Policy,
|
||||
Dependency,
|
||||
ContractViolation,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InterceptorErrorCategory {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Policy => "policy",
|
||||
Self::Dependency => "dependency",
|
||||
Self::ContractViolation => "contract_violation",
|
||||
Self::Internal => "internal",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{category}: {diagnostic}")]
|
||||
pub struct InterceptorError {
|
||||
category: InterceptorErrorCategory,
|
||||
diagnostic: String,
|
||||
}
|
||||
|
||||
impl InterceptorError {
|
||||
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
|
||||
let mut diagnostic = diagnostic.into();
|
||||
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
|
||||
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
|
||||
while !diagnostic.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
diagnostic.truncate(end);
|
||||
}
|
||||
Self {
|
||||
category,
|
||||
diagnostic,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category(&self) -> InterceptorErrorCategory {
|
||||
self.category
|
||||
}
|
||||
|
||||
pub fn diagnostic(&self) -> &str {
|
||||
&self.diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
/// The lifecycle phase at which an interceptor callback executes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InterceptorPhase {
|
||||
#[default]
|
||||
PromptSubmit,
|
||||
PendingHistoryAppends,
|
||||
PreLlmRequest,
|
||||
PreToolCall,
|
||||
PostToolCall,
|
||||
AssistantTurnEnd,
|
||||
RunExit,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InterceptorPhase {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::PromptSubmit => "prompt_submit",
|
||||
Self::PendingHistoryAppends => "pending_history_appends",
|
||||
Self::PreLlmRequest => "pre_llm_request",
|
||||
Self::PreToolCall => "pre_tool_call",
|
||||
Self::PostToolCall => "post_tool_call",
|
||||
Self::AssistantTurnEnd => "assistant_turn_end",
|
||||
Self::RunExit => "run_exit",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct InterceptorRunId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct InterceptorTurnId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum InterceptorCallId {
|
||||
Llm(u64),
|
||||
Tool(String),
|
||||
}
|
||||
|
||||
/// Saturating public counter used by interceptor contexts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub struct InterceptorCounter(u32);
|
||||
|
||||
impl InterceptorCounter {
|
||||
pub fn from_usize(value: usize) -> Self {
|
||||
Self(u32::try_from(value).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
pub fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct InterceptorCounters {
|
||||
pub invocation: InterceptorCounter,
|
||||
pub engine_turn: InterceptorCounter,
|
||||
pub run_turn: InterceptorCounter,
|
||||
pub llm_call: InterceptorCounter,
|
||||
pub tool_batch: InterceptorCounter,
|
||||
pub tool_call: InterceptorCounter,
|
||||
}
|
||||
|
||||
/// Identity, phase, and bounded counters common to every lifecycle callback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct InterceptorInvocation {
|
||||
pub run_id: InterceptorRunId,
|
||||
pub turn_id: Option<InterceptorTurnId>,
|
||||
pub call_id: Option<InterceptorCallId>,
|
||||
pub phase: InterceptorPhase,
|
||||
pub counters: InterceptorCounters,
|
||||
}
|
||||
|
||||
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{phase} interceptor failed: {error}")]
|
||||
pub struct InterceptorFailure {
|
||||
phase: InterceptorPhase,
|
||||
#[source]
|
||||
error: InterceptorError,
|
||||
}
|
||||
|
||||
impl InterceptorFailure {
|
||||
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
|
||||
Self { phase, error }
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> InterceptorPhase {
|
||||
self.phase
|
||||
}
|
||||
|
||||
pub fn error(&self) -> &InterceptorError {
|
||||
&self.error
|
||||
}
|
||||
}
|
||||
|
||||
pub type InterceptorResult<T> = Result<T, InterceptorError>;
|
||||
|
||||
// =============================================================================
|
||||
// Lifecycle Contexts
|
||||
// =============================================================================
|
||||
|
||||
pub struct PromptSubmitContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub item: &'a mut Item,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
pub struct PendingHistoryAppendsContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
pub struct PreLlmRequestContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub items: &'a mut Vec<Item>,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
pub struct AssistantTurnEndContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub assistant_entries: &'a [HistoryEntry<A>],
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
pub tool_calls: &'a [ToolCall],
|
||||
}
|
||||
|
||||
pub struct RunExitContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub exit: &'a EngineRunExit,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Action Enums
|
||||
// =============================================================================
|
||||
@@ -86,9 +280,9 @@ pub enum PostToolAction {
|
||||
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEndAction {
|
||||
/// Turn is finished, return to caller.
|
||||
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
|
||||
Finish,
|
||||
/// Continue with additional messages injected into history.
|
||||
/// Commit additional messages, then continue through the natural next phase.
|
||||
ContinueWithMessages(Vec<Item>),
|
||||
/// Pause execution (can be resumed later).
|
||||
Pause,
|
||||
@@ -99,8 +293,9 @@ pub enum TurnEndAction {
|
||||
// =============================================================================
|
||||
|
||||
/// Context for pre-tool-call decisions.
|
||||
pub struct ToolCallInfo {
|
||||
/// Tool call information (modifiable).
|
||||
pub struct ToolCallInfo<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
pub call: ToolCall,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
@@ -111,10 +306,11 @@ pub struct ToolCallInfo {
|
||||
}
|
||||
|
||||
/// Context for post-tool-call decisions.
|
||||
pub struct ToolResultInfo {
|
||||
/// Original tool call.
|
||||
pub struct ToolResultInfo<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
pub call: ToolCall,
|
||||
/// Tool execution result (modifiable).
|
||||
/// Committed terminal tool execution result.
|
||||
pub result: ToolResult,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
@@ -130,14 +326,22 @@ pub struct ToolResultInfo {
|
||||
|
||||
/// Intercepts the Engine execution loop at key decision points.
|
||||
///
|
||||
/// All methods have default implementations that let the Engine
|
||||
/// proceed without intervention. Callers provide richer implementations for
|
||||
/// approval flows, permission checks, etc.
|
||||
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
|
||||
/// keeping implementation failure separate from the method's control-flow
|
||||
/// action. The Engine reports a failure as a typed run interruption annotated
|
||||
/// with the exact [`InterceptorPhase`] that failed.
|
||||
///
|
||||
/// All methods have default implementations that let the Engine proceed
|
||||
/// without intervention. Callers provide richer implementations for approval
|
||||
/// flows, permission checks, and other trusted host adaptation.
|
||||
#[async_trait]
|
||||
pub trait Interceptor: Send + Sync {
|
||||
/// Called after receiving user input, before adding to history.
|
||||
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
|
||||
PromptAction::Continue
|
||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||
/// Called after receiving user input, before adding it to Engine history.
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
_context: PromptSubmitContext<'_, A>,
|
||||
) -> InterceptorResult<PromptAction> {
|
||||
Ok(PromptAction::Continue)
|
||||
}
|
||||
|
||||
/// Items that should be **committed to `engine.history`** just
|
||||
@@ -158,7 +362,10 @@ pub trait Interceptor: Send + Sync {
|
||||
/// reproducible per-request transformations (pruning, content
|
||||
/// trimming, cache anchors) that depend only on the existing
|
||||
/// history.
|
||||
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
||||
async fn pending_history_appends(
|
||||
&self,
|
||||
_context: PendingHistoryAppendsContext<'_, A>,
|
||||
) -> InterceptorResult<Vec<Item>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
@@ -170,27 +377,42 @@ pub trait Interceptor: Send + Sync {
|
||||
/// If an interceptor derives a human/model-visible nudge from the current
|
||||
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
||||
/// commits it to history before the request is sent.
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
PreRequestAction::Continue
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, A>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
Ok(PreRequestAction::Continue)
|
||||
}
|
||||
|
||||
/// Called before each tool is executed.
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::Continue
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_, A>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
|
||||
/// Called after each tool completes.
|
||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||
PostToolAction::Continue
|
||||
/// Called after each tool reaches one terminal result and that result is committed.
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_info: &ToolResultInfo<'_, A>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
|
||||
/// Called when a turn ends with no tool calls.
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
TurnEndAction::Finish
|
||||
/// Called after every terminal assistant response is committed and before
|
||||
/// the Engine decides whether to execute tools, continue, or finish.
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_, A>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
|
||||
/// Called when execution is interrupted (abort or cancel).
|
||||
async fn on_abort(&self, _reason: &str) {}
|
||||
/// Called once for the terminal outcome of each public run or resume call.
|
||||
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Default interceptor: no intervention. Engine proceeds through the loop
|
||||
@@ -198,4 +420,4 @@ pub trait Interceptor: Send + Sync {
|
||||
pub(crate) struct DefaultInterceptor;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for DefaultInterceptor {}
|
||||
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
|
||||
|
||||
@@ -26,7 +26,13 @@ pub use engine::{
|
||||
};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use history::{History, HistoryEntry};
|
||||
pub use interceptor::Interceptor;
|
||||
pub use interceptor::{
|
||||
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
|
||||
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
|
||||
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
|
||||
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
|
||||
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
|
||||
};
|
||||
pub use message::{ContentPart, Item, Message, Role};
|
||||
pub use tool::{
|
||||
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
mod common;
|
||||
|
||||
use agen::interceptor::{
|
||||
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
|
||||
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
|
||||
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn completed_text_events(text: &str) -> Vec<Event> {
|
||||
vec![
|
||||
@@ -47,6 +54,125 @@ async fn run_preserves_item_annotations_without_projecting_them() {
|
||||
assert_eq!(history.items_cloned().len(), 2);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AnnotationObservingInterceptor {
|
||||
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
|
||||
}
|
||||
|
||||
impl AnnotationObservingInterceptor {
|
||||
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
|
||||
self.observed.lock().unwrap().push((
|
||||
invocation.clone(),
|
||||
history
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.clone())
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor<String> for AnnotationObservingInterceptor {
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
context: PromptSubmitContext<'_, String>,
|
||||
) -> InterceptorResult<PromptAction> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(PromptAction::Continue)
|
||||
}
|
||||
|
||||
async fn pending_history_appends(
|
||||
&self,
|
||||
context: PendingHistoryAppendsContext<'_, String>,
|
||||
) -> InterceptorResult<Vec<Item>> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
context: PreLlmRequestContext<'_, String>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(PreRequestAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
context: AssistantTurnEndContext<'_, String>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
assert_eq!(context.assistant_entries.len(), 1);
|
||||
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
|
||||
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
||||
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
engine.set_interceptor(AnnotationObservingInterceptor {
|
||||
observed: observed.clone(),
|
||||
});
|
||||
let mut history = History::<String>::new();
|
||||
let mut next = 0usize;
|
||||
let mut annotate = |item: &Item| {
|
||||
next += 1;
|
||||
let kind = if item.is_assistant_message() {
|
||||
"assistant"
|
||||
} else {
|
||||
"user"
|
||||
};
|
||||
Ok(format!("{next}:{kind}"))
|
||||
};
|
||||
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "hello", &mut annotate)
|
||||
.await;
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
||||
|
||||
let observed = observed.lock().unwrap();
|
||||
let phases: Vec<_> = observed
|
||||
.iter()
|
||||
.map(|(invocation, _)| invocation.phase)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
phases,
|
||||
[
|
||||
InterceptorPhase::PromptSubmit,
|
||||
InterceptorPhase::PendingHistoryAppends,
|
||||
InterceptorPhase::PreLlmRequest,
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
InterceptorPhase::RunExit,
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
observed
|
||||
.iter()
|
||||
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
|
||||
);
|
||||
assert_eq!(
|
||||
observed
|
||||
.iter()
|
||||
.map(|(invocation, _)| invocation.counters.invocation.get())
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 2, 3, 4]
|
||||
);
|
||||
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
|
||||
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
|
||||
assert_eq!(observed[1].1, ["1:user"]);
|
||||
assert_eq!(observed[2].1, ["1:user"]);
|
||||
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
|
||||
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_failure_does_not_make_item_live() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
|
||||
@@ -10,9 +10,16 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::Item;
|
||||
use agen::interceptor::{
|
||||
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
||||
AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
|
||||
InterceptorPhase as InterceptorPoint, InterceptorResult, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES,
|
||||
PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext, PreRequestAction,
|
||||
PreToolAction, PromptAction, PromptSubmitContext, RunExitContext, ToolCallInfo, ToolResultInfo,
|
||||
TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::{
|
||||
ClientError, LlmClient, Request, ResponseStream,
|
||||
event::{Event, ResponseStatus, StatusEvent},
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
|
||||
use async_trait::async_trait;
|
||||
@@ -613,12 +620,15 @@ struct YieldOnce {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for YieldOnce {
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, ()>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,12 +638,15 @@ struct PauseToolOnce {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PauseToolOnce {
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreToolAction::Pause
|
||||
} else {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,13 +656,509 @@ struct ContinueTurnOnce {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ContinueTurnOnce {
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FailingLifecycleInterceptor {
|
||||
failure: InterceptorPoint,
|
||||
calls: Arc<Mutex<Vec<InterceptorPoint>>>,
|
||||
}
|
||||
|
||||
impl FailingLifecycleInterceptor {
|
||||
fn new(failure: InterceptorPoint) -> Self {
|
||||
Self {
|
||||
failure,
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn record<T>(&self, point: InterceptorPoint, action: T) -> InterceptorResult<T> {
|
||||
self.calls.lock().unwrap().push(point);
|
||||
if self.failure == point {
|
||||
Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Policy,
|
||||
format!("{point} rejected"),
|
||||
))
|
||||
} else {
|
||||
Ok(action)
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<InterceptorPoint> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for FailingLifecycleInterceptor {
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
_context: PromptSubmitContext<'_, ()>,
|
||||
) -> InterceptorResult<PromptAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PromptSubmit, PromptAction::Continue)
|
||||
}
|
||||
|
||||
async fn pending_history_appends(
|
||||
&self,
|
||||
_context: PendingHistoryAppendsContext<'_, ()>,
|
||||
) -> InterceptorResult<Vec<Item>> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PendingHistoryAppends, Vec::new())
|
||||
}
|
||||
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, ()>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PreLlmRequest, PreRequestAction::Continue)
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PreToolCall, PreToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PostToolCall, PostToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
tokio::task::yield_now().await;
|
||||
assert!(context.history.ends_with(context.assistant_entries));
|
||||
if !context.tool_calls.is_empty() {
|
||||
assert_eq!(
|
||||
context
|
||||
.assistant_entries
|
||||
.iter()
|
||||
.filter(|entry| matches!(&entry.item, Item::ToolCall { .. }))
|
||||
.count(),
|
||||
context.tool_calls.len()
|
||||
);
|
||||
}
|
||||
self.record(InterceptorPoint::AssistantTurnEnd, TurnEndAction::Finish)
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::RunExit, ())
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_interceptor_calls(failure: InterceptorPoint) -> Vec<InterceptorPoint> {
|
||||
use InterceptorPoint as Point;
|
||||
|
||||
let mut calls = match failure {
|
||||
Point::PromptSubmit => vec![Point::PromptSubmit],
|
||||
Point::PendingHistoryAppends => {
|
||||
vec![Point::PromptSubmit, Point::PendingHistoryAppends]
|
||||
}
|
||||
Point::PreLlmRequest => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
],
|
||||
Point::PreToolCall => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
Point::PreToolCall,
|
||||
],
|
||||
Point::PostToolCall => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
Point::PreToolCall,
|
||||
Point::PostToolCall,
|
||||
],
|
||||
Point::AssistantTurnEnd => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
],
|
||||
Point::RunExit => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
],
|
||||
};
|
||||
calls.push(Point::RunExit);
|
||||
calls
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_failures_are_typed_and_terminal_observer_preserves_original_exit() {
|
||||
use InterceptorPoint as Point;
|
||||
|
||||
for failure_point in [
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::PreToolCall,
|
||||
Point::PostToolCall,
|
||||
Point::AssistantTurnEnd,
|
||||
Point::RunExit,
|
||||
] {
|
||||
let interceptor = FailingLifecycleInterceptor::new(failure_point);
|
||||
let needs_tool = matches!(failure_point, Point::PreToolCall | Point::PostToolCall);
|
||||
let events = if needs_tool {
|
||||
vec![
|
||||
Event::tool_use_start(0, "call-1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
} else {
|
||||
completed_text_events()
|
||||
};
|
||||
let mut engine = Engine::new(MockLlmClient::new(events));
|
||||
engine.register_tool(CountingTool::new("count_tool").definition());
|
||||
engine.set_interceptor(interceptor.clone());
|
||||
let mut history = History::new();
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
let exit = engine.run(&mut history, "test").await;
|
||||
let failure = if failure_point == Point::RunExit {
|
||||
assert!(matches!(exit, EngineRunExit::Finished));
|
||||
engine
|
||||
.last_run_exit_observer_failure()
|
||||
.expect("terminal observer diagnostic should be retained")
|
||||
} else {
|
||||
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||
EngineError::Interceptor(failure),
|
||||
)) = &exit
|
||||
else {
|
||||
panic!("expected typed interceptor interruption at {failure_point}, got {exit:?}");
|
||||
};
|
||||
failure
|
||||
};
|
||||
assert_eq!(failure.phase(), failure_point);
|
||||
assert_eq!(
|
||||
failure.error().diagnostic(),
|
||||
format!("{failure_point} rejected")
|
||||
);
|
||||
assert_eq!(
|
||||
interceptor.calls(),
|
||||
expected_interceptor_calls(failure_point)
|
||||
);
|
||||
if failure_point == Point::PostToolCall {
|
||||
assert!(
|
||||
history
|
||||
.items()
|
||||
.any(|item| matches!(item, Item::ToolResult { .. })),
|
||||
"post-tool failure must not precede terminal output commit"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_error_keeps_typed_category_and_bounded_utf8_diagnostic() {
|
||||
let error = InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
"界".repeat(MAX_INTERCEPTOR_DIAGNOSTIC_BYTES),
|
||||
);
|
||||
assert_eq!(error.category(), InterceptorErrorCategory::Dependency);
|
||||
assert!(error.diagnostic().len() <= MAX_INTERCEPTOR_DIAGNOSTIC_BYTES);
|
||||
assert!(
|
||||
error
|
||||
.diagnostic()
|
||||
.is_char_boundary(error.diagnostic().len())
|
||||
);
|
||||
}
|
||||
|
||||
struct FailingRunExitObserver {
|
||||
pause: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for FailingRunExitObserver {
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
Ok(if self.pause {
|
||||
TurnEndAction::Pause
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
})
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||
Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
"terminal audit unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_observer_failure_preserves_paused_and_interrupted_exits() {
|
||||
let mut paused_engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
paused_engine.set_interceptor(FailingRunExitObserver { pause: true });
|
||||
let mut paused_history = History::new();
|
||||
let mut paused_engine = paused_engine.lock(&paused_history);
|
||||
assert!(matches!(
|
||||
paused_engine.run(&mut paused_history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(
|
||||
paused_engine
|
||||
.last_run_exit_observer_failure()
|
||||
.expect("paused observer diagnostic")
|
||||
.error()
|
||||
.category(),
|
||||
InterceptorErrorCategory::Dependency
|
||||
);
|
||||
|
||||
let mut interrupted_engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
interrupted_engine.set_max_turns(Some(0));
|
||||
interrupted_engine.set_interceptor(FailingRunExitObserver { pause: false });
|
||||
let mut interrupted_history = History::new();
|
||||
let mut interrupted_engine = interrupted_engine.lock(&interrupted_history);
|
||||
assert!(matches!(
|
||||
interrupted_engine
|
||||
.run(&mut interrupted_history, "limit")
|
||||
.await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||
));
|
||||
assert_eq!(
|
||||
interrupted_engine
|
||||
.last_run_exit_observer_failure()
|
||||
.expect("interrupted observer diagnostic")
|
||||
.phase(),
|
||||
InterceptorPoint::RunExit
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TerminalMode {
|
||||
Finish,
|
||||
PauseOnce,
|
||||
Yield,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RecordingTerminalInterceptor {
|
||||
mode: TerminalMode,
|
||||
assistant_turns: Arc<AtomicUsize>,
|
||||
exits: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
impl RecordingTerminalInterceptor {
|
||||
fn new(mode: TerminalMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
assistant_turns: Arc::new(AtomicUsize::new(0)),
|
||||
exits: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn exits(&self) -> Vec<&'static str> {
|
||||
self.exits.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for RecordingTerminalInterceptor {
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, ()>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
Ok(if self.mode == TerminalMode::Yield {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::Continue
|
||||
})
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
assert!(!context.assistant_entries.is_empty());
|
||||
assert!(
|
||||
context.history.ends_with(context.assistant_entries),
|
||||
"assistant-turn callback must observe committed terminal items"
|
||||
);
|
||||
let turn = self.assistant_turns.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(if self.mode == TerminalMode::PauseOnce && turn == 0 {
|
||||
TurnEndAction::Pause
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
})
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||
let kind = match context.exit {
|
||||
EngineRunExit::Finished => "finished",
|
||||
EngineRunExit::Paused => "paused",
|
||||
EngineRunExit::Yielded => "yielded",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => "limit",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded) => "context",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => "cancelled",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(_)) => "unexpected",
|
||||
};
|
||||
self.exits.lock().unwrap().push(kind);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ContextWindowClient;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for ContextWindowClient {
|
||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||
Err(ClientError::ContextWindowExceeded)
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_observer_runs_once_for_every_exit_and_interruption_kind() {
|
||||
let finished = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(finished.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "finish").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(finished.exits(), ["finished"]);
|
||||
|
||||
let yielded = RecordingTerminalInterceptor::new(TerminalMode::Yield);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(yielded.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "yield").await,
|
||||
EngineRunExit::Yielded
|
||||
));
|
||||
assert_eq!(yielded.exits(), ["yielded"]);
|
||||
|
||||
let limited = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(0));
|
||||
engine.set_interceptor(limited.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "limit").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||
));
|
||||
assert_eq!(limited.exits(), ["limit"]);
|
||||
|
||||
let cancelled = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(cancelled.clone());
|
||||
engine.cancel();
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "cancel").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||
));
|
||||
assert_eq!(cancelled.exits(), ["cancelled"]);
|
||||
|
||||
let context = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(ContextWindowClient);
|
||||
engine.set_interceptor(context.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "context").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded)
|
||||
));
|
||||
assert_eq!(context.exits(), ["context"]);
|
||||
|
||||
let unexpected = FailingLifecycleInterceptor::new(InterceptorPoint::PromptSubmit);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(unexpected.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "fail").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::Interceptor(
|
||||
_
|
||||
)))
|
||||
));
|
||||
assert_eq!(
|
||||
unexpected
|
||||
.calls()
|
||||
.iter()
|
||||
.filter(|point| **point == InterceptorPoint::RunExit)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_observer_does_not_duplicate_on_resume() {
|
||||
let interceptor = RecordingTerminalInterceptor::new(TerminalMode::PauseOnce);
|
||||
let first_response = vec![
|
||||
Event::tool_use_start(0, "call-1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let client = MockLlmClient::with_responses(vec![first_response, completed_text_events()]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(interceptor.clone());
|
||||
let mut history = History::new();
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(interceptor.exits(), ["paused"]);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
0,
|
||||
"pause must retain the pending tool phase"
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(interceptor.exits(), ["paused", "finished"]);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
1,
|
||||
"resume must execute the retained tool once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -6,13 +6,18 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
||||
use agen::interceptor::{
|
||||
Interceptor, InterceptorError, InterceptorErrorCategory, InterceptorPhase, InterceptorResult,
|
||||
PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
|
||||
ToolResultDisposition,
|
||||
};
|
||||
use agen::{Engine, History, Item, ToolExecutionPolicy};
|
||||
use agen::{
|
||||
Engine, EngineError, EngineRunExit, History, Item, RunInterruptionReason, ToolExecutionPolicy,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
mod common;
|
||||
@@ -905,24 +910,30 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ContextPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
self.pre_contexts.lock().unwrap().push(info.context.clone());
|
||||
match info.call.name.as_str() {
|
||||
Ok(match info.call.name.as_str() {
|
||||
"skip_tool" => PreToolAction::Skip,
|
||||
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
|
||||
&info.call.id,
|
||||
ToolOutput::from("synthetic result".to_string()),
|
||||
)),
|
||||
_ => PreToolAction::Continue,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
self.post_contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(info.context.clone());
|
||||
PostToolAction::Continue
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,12 +1005,15 @@ async fn test_before_tool_call_skip() {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for BlockingPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
if info.call.name == "blocked_tool" {
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
Ok(if info.call.name == "blocked_tool" {
|
||||
PreToolAction::Skip
|
||||
} else {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,9 +1035,9 @@ async fn test_before_tool_call_skip() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hook: post_tool_call - verify that results can be modified
|
||||
/// Hook: post_tool_call - verify that the committed terminal result is observed.
|
||||
#[tokio::test]
|
||||
async fn test_post_tool_call_modification() {
|
||||
async fn test_post_tool_call_observes_committed_result() {
|
||||
// Prepare responses for multiple requests
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
// First request: tool call
|
||||
@@ -1074,40 +1088,51 @@ async fn test_post_tool_call_modification() {
|
||||
|
||||
engine.register_tool(simple_tool_definition());
|
||||
|
||||
// Policy to modify results
|
||||
struct ModifyingPolicy {
|
||||
modified_content: Arc<std::sync::Mutex<Option<String>>>,
|
||||
// Policy to observe the committed terminal result.
|
||||
struct ObservingPolicy {
|
||||
observed_content: Arc<std::sync::Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ModifyingPolicy {
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
info.result.summary = format!("[Modified] {}", info.result.summary);
|
||||
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone());
|
||||
PostToolAction::Continue
|
||||
impl Interceptor for ObservingPolicy {
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
assert_eq!(info.invocation.phase, InterceptorPhase::PostToolCall);
|
||||
assert_eq!(
|
||||
info.invocation.call_id,
|
||||
Some(agen::InterceptorCallId::Tool(info.call.id.clone()))
|
||||
);
|
||||
assert!(matches!(
|
||||
info.history.last().map(|entry| &entry.item),
|
||||
Some(Item::ToolResult { call_id, .. }) if call_id == &info.call.id
|
||||
));
|
||||
*self.observed_content.lock().unwrap() = Some(info.result.summary.clone());
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
let modified_content = Arc::new(std::sync::Mutex::new(None));
|
||||
engine.set_interceptor(ModifyingPolicy {
|
||||
modified_content: modified_content.clone(),
|
||||
let observed_content = Arc::new(std::sync::Mutex::new(None));
|
||||
engine.set_interceptor(ObservingPolicy {
|
||||
observed_content: observed_content.clone(),
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||
let result = engine.run(&mut history, "Test modification").await;
|
||||
let result = engine.run(&mut history, "Test observation").await;
|
||||
|
||||
assert!(
|
||||
matches!(result.result, agen::EngineRunExit::Finished),
|
||||
"Engine should complete"
|
||||
);
|
||||
|
||||
// Verify hook was called and content was modified
|
||||
let content = modified_content.lock().unwrap().clone();
|
||||
assert!(content.is_some(), "Hook should have been called");
|
||||
assert!(
|
||||
content.unwrap().contains("[Modified]"),
|
||||
"Result should be modified"
|
||||
);
|
||||
// Verify the interceptor observed the exact committed result.
|
||||
let observed = observed_content.lock().unwrap().clone();
|
||||
assert_eq!(observed.as_deref(), Some("Original Result"));
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolResult { summary, .. } if summary == "Original Result"
|
||||
)));
|
||||
}
|
||||
|
||||
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
|
||||
@@ -1143,11 +1168,14 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for SyntheticPolicy {
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::SyntheticResult(ToolResult::error(
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
Ok(PreToolAction::SyntheticResult(ToolResult::error(
|
||||
info.call.id.clone(),
|
||||
"permission denied",
|
||||
))
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,6 +1195,80 @@ async fn test_before_tool_call_synthetic_result_committed() {
|
||||
)));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InvalidIdentityMode {
|
||||
ContinuedCall,
|
||||
SyntheticResult,
|
||||
}
|
||||
|
||||
struct InvalidIdentityPolicy(InvalidIdentityMode);
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for InvalidIdentityPolicy {
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
assert_eq!(info.invocation.phase, InterceptorPhase::PreToolCall);
|
||||
assert_eq!(
|
||||
info.invocation.call_id,
|
||||
Some(agen::InterceptorCallId::Tool("call_1".to_string()))
|
||||
);
|
||||
assert!(matches!(
|
||||
info.history.last().map(|entry| &entry.item),
|
||||
Some(Item::ToolCall { call_id, .. }) if call_id == "call_1"
|
||||
));
|
||||
Ok(match self.0 {
|
||||
InvalidIdentityMode::ContinuedCall => {
|
||||
info.call.id = "different-call".to_string();
|
||||
PreToolAction::Continue
|
||||
}
|
||||
InvalidIdentityMode::SyntheticResult => PreToolAction::SyntheticResult(
|
||||
ToolResult::error("different-call", "invalid synthetic result"),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_cannot_change_tool_call_identity() {
|
||||
for mode in [
|
||||
InvalidIdentityMode::ContinuedCall,
|
||||
InvalidIdentityMode::SyntheticResult,
|
||||
] {
|
||||
let client = MockLlmClient::new(vec![
|
||||
Event::tool_use_start(0, "call_1", "echo"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(SlowTool::new("echo", 1).definition());
|
||||
engine.set_interceptor(InvalidIdentityPolicy(mode));
|
||||
let mut history = History::new();
|
||||
|
||||
let result = engine.run(&mut history, "identity").await;
|
||||
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||
EngineError::Interceptor(failure),
|
||||
)) = result.result
|
||||
else {
|
||||
panic!("invalid tool identity must interrupt with a typed failure");
|
||||
};
|
||||
assert_eq!(failure.phase(), InterceptorPhase::PreToolCall);
|
||||
assert_eq!(
|
||||
failure.error().category(),
|
||||
InterceptorErrorCategory::ContractViolation
|
||||
);
|
||||
assert!(
|
||||
!history
|
||||
.items()
|
||||
.any(|item| matches!(item, Item::ToolResult { .. }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||
let client = MockLlmClient::new(vec![
|
||||
@@ -1181,16 +1283,24 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||
let tool = SlowTool::new("confirmed", 1);
|
||||
engine.register_tool(tool.definition());
|
||||
|
||||
struct AbortAfterResult;
|
||||
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||
struct AbortAfterResult {
|
||||
lifecycle: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interceptor for AbortAfterResult {
|
||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||
PostToolAction::Abort("policy stopped the run".to_string())
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
self.lifecycle.lock().unwrap().push("post_tool_call");
|
||||
Ok(PostToolAction::Abort("policy stopped the run".to_string()))
|
||||
}
|
||||
}
|
||||
engine.set_interceptor(AbortAfterResult);
|
||||
engine.set_interceptor(AbortAfterResult {
|
||||
lifecycle: observed.clone(),
|
||||
});
|
||||
|
||||
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
|
||||
let published = observed.clone();
|
||||
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
|
||||
let committed = observed.clone();
|
||||
@@ -1210,7 +1320,7 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||
assert_eq!(tool.call_count(), 1);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
["committed", "published", "run-returned"]
|
||||
["committed", "published", "post_tool_call", "run-returned"]
|
||||
);
|
||||
assert!(matches!(
|
||||
output.result,
|
||||
@@ -1239,3 +1349,93 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
|
||||
} if call_id == "call_confirmed"
|
||||
)));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PostToolStopMode {
|
||||
Abort,
|
||||
Failure,
|
||||
}
|
||||
|
||||
struct StopFirstParallelResult(PostToolStopMode);
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for StopFirstParallelResult {
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
if info.call.id != "call_fast" {
|
||||
return Ok(PostToolAction::Continue);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
match self.0 {
|
||||
PostToolStopMode::Abort => Ok(PostToolAction::Abort("stop parallel batch".to_string())),
|
||||
PostToolStopMode::Failure => Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Policy,
|
||||
"reject parallel batch",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning() {
|
||||
for mode in [PostToolStopMode::Abort, PostToolStopMode::Failure] {
|
||||
let first_response = vec![
|
||||
Event::tool_use_start(0, "call_fast", "fast"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "call_ready", "ready"),
|
||||
Event::tool_input_delta(1, r#"{}"#),
|
||||
Event::tool_use_stop(1),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let second_response = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "next run completed"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let client = MockLlmClient::with_responses(vec![first_response, second_response]);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(SlowTool::new("fast", 0).definition());
|
||||
engine.register_tool(SlowTool::new("ready", 1).definition());
|
||||
engine.set_interceptor(StopFirstParallelResult(mode));
|
||||
let mut history = History::new();
|
||||
|
||||
let output = engine.run(&mut history, "parallel stop").await;
|
||||
match mode {
|
||||
PostToolStopMode::Abort => assert!(matches!(
|
||||
output.result,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||
EngineError::Aborted(ref reason)
|
||||
)) if reason == "stop parallel batch"
|
||||
)),
|
||||
PostToolStopMode::Failure => assert!(matches!(
|
||||
output.result,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||
EngineError::Interceptor(ref failure)
|
||||
)) if failure.phase() == InterceptorPhase::PostToolCall
|
||||
)),
|
||||
}
|
||||
|
||||
let terminal_ids: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match &entry.item {
|
||||
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(terminal_ids.len(), 2);
|
||||
assert!(terminal_ids.contains(&"call_fast"));
|
||||
assert!(terminal_ids.contains(&"call_ready"));
|
||||
|
||||
let mut engine = output.engine;
|
||||
let next = engine.run(&mut history, "next run").await;
|
||||
assert!(matches!(next, EngineRunExit::Finished));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
pub use workspace_api::{
|
||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
||||
BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
|
||||
CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
|
||||
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
|
||||
RuntimeSummary as BackendRuntimeSummary,
|
||||
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
|
||||
WorkerImplementationSummary as BackendWorkerImplementationSummary,
|
||||
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
|
||||
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
|
||||
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
||||
WorkerOperationState as BackendWorkerOperationState,
|
||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||
@@ -171,6 +177,47 @@ struct UploadedFileResponse {
|
||||
file: protocol::UploadedFileRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendWorkerLaunchTarget {
|
||||
pub base_url: String,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BackendWorkerLaunchTarget {
|
||||
pub fn new(base_url: impl Into<String>, workspace_id: Option<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
workspace_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
|
||||
self.workspace_id = Some(workspace_id.into());
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> Option<&str> {
|
||||
self.workspace_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn runtime_target(
|
||||
&self,
|
||||
runtime_id: impl Into<String>,
|
||||
worker_id: impl Into<String>,
|
||||
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
|
||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
||||
BackendRuntimeClientError::InvalidTarget(
|
||||
"workspace_id is required before creating a Backend worker".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(BackendRuntimeTarget::new(
|
||||
self.base_url.clone(),
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendRuntimeListTarget {
|
||||
pub base_url: String,
|
||||
@@ -255,6 +302,58 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_backend_worker_launch_options(
|
||||
target: &BackendWorkerLaunchTarget,
|
||||
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
|
||||
validate_launch_target(target)?;
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
get_backend_worker_launch_options_with_client(target, &api).await
|
||||
}
|
||||
|
||||
async fn get_backend_worker_launch_options_with_client(
|
||||
target: &BackendWorkerLaunchTarget,
|
||||
api: &BackendApiClient,
|
||||
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
|
||||
let path = backend_workspace_workers_launch_options_path(
|
||||
target
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.expect("validated Backend Workspace scope"),
|
||||
);
|
||||
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
||||
let response = api.require_success(response).await?;
|
||||
Ok(response.json::<BackendWorkerLaunchOptions>().await?)
|
||||
}
|
||||
|
||||
pub async fn create_backend_worker(
|
||||
target: &BackendWorkerLaunchTarget,
|
||||
request: &BackendCreateWorkerRequest,
|
||||
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
|
||||
validate_launch_target(target)?;
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
create_backend_worker_with_client(target, request, &api).await
|
||||
}
|
||||
|
||||
async fn create_backend_worker_with_client(
|
||||
target: &BackendWorkerLaunchTarget,
|
||||
request: &BackendCreateWorkerRequest,
|
||||
api: &BackendApiClient,
|
||||
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
|
||||
let path = backend_workspace_workers_path(
|
||||
target
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.expect("validated Backend Workspace scope"),
|
||||
);
|
||||
let response = api
|
||||
.request(HttpMethod::POST, &path)?
|
||||
.json(request)
|
||||
.send()
|
||||
.await?;
|
||||
let response = api.require_success(response).await?;
|
||||
Ok(response.json::<BackendCreateWorkerResponse>().await?)
|
||||
}
|
||||
|
||||
pub async fn list_backend_workers(
|
||||
target: &BackendRuntimeListTarget,
|
||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
||||
@@ -462,6 +561,30 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_launch_target(
|
||||
target: &BackendWorkerLaunchTarget,
|
||||
) -> Result<(), BackendRuntimeClientError> {
|
||||
if target.base_url.trim().is_empty() {
|
||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||
"Backend API base URL is required".to_string(),
|
||||
));
|
||||
}
|
||||
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||
"Backend API base URL must start with http:// or https://".to_string(),
|
||||
));
|
||||
}
|
||||
match target.workspace_id.as_deref() {
|
||||
Some("") => Err(BackendRuntimeClientError::InvalidTarget(
|
||||
"workspace_id must not be empty".to_string(),
|
||||
)),
|
||||
None => Err(BackendRuntimeClientError::InvalidTarget(
|
||||
"workspace selection is required before creating a Backend worker".to_string(),
|
||||
)),
|
||||
Some(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_list_target(
|
||||
target: &BackendRuntimeListTarget,
|
||||
) -> Result<(), BackendRuntimeClientError> {
|
||||
@@ -496,6 +619,17 @@ fn validate_list_target(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backend_workspace_workers_path(workspace_id: &str) -> String {
|
||||
format!("/api/w/{}/workers", path_segment_encode(workspace_id))
|
||||
}
|
||||
|
||||
fn backend_workspace_workers_launch_options_path(workspace_id: &str) -> String {
|
||||
format!(
|
||||
"{}/launch-options",
|
||||
backend_workspace_workers_path(workspace_id)
|
||||
)
|
||||
}
|
||||
|
||||
fn backend_runtimes_path(workspace_id: &str) -> String {
|
||||
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
|
||||
}
|
||||
@@ -580,6 +714,155 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn serve_json_once(body: serde_json::Value) -> (String, tokio::task::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let header_end = loop {
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let read = socket.read(&mut buffer).await.unwrap();
|
||||
assert!(read > 0, "client closed before sending HTTP headers");
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||
break position + 4;
|
||||
}
|
||||
};
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
while request.len() < header_end + content_length {
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let read = socket.read(&mut buffer).await.unwrap();
|
||||
assert!(read > 0, "client closed before sending HTTP body");
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
|
||||
let body = serde_json::to_vec(&body).unwrap();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
socket.write_all(&body).await.unwrap();
|
||||
String::from_utf8(request).unwrap()
|
||||
});
|
||||
(base_url, task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_options_request_uses_workspace_path_and_bearer_auth() {
|
||||
let (base_url, server) = serve_json_once(serde_json::json!({
|
||||
"workspace_id": "team main",
|
||||
"runtimes": [{
|
||||
"runtime_id": "embedded",
|
||||
"display_name": "Embedded",
|
||||
"built_in": true,
|
||||
"worker_creation_available": true,
|
||||
"working_directory_required": false,
|
||||
"status": "online",
|
||||
"diagnostics": []
|
||||
}],
|
||||
"default_profile": "builtin:default",
|
||||
"profiles": [{
|
||||
"id": "builtin:default",
|
||||
"label": "Default",
|
||||
"description": ""
|
||||
}],
|
||||
"repositories": [],
|
||||
"working_directories": [],
|
||||
"diagnostics": []
|
||||
}))
|
||||
.await;
|
||||
let target = BackendWorkerLaunchTarget::new(&base_url, Some("team main".to_string()));
|
||||
let api = BackendApiClient::from_access_token_for_test(&base_url, "launch-secret").unwrap();
|
||||
|
||||
let response = get_backend_worker_launch_options_with_client(&target, &api)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.runtimes[0].runtime_id, "embedded");
|
||||
let request = server.await.unwrap();
|
||||
assert!(request.starts_with("GET /api/w/team%20main/workers/launch-options HTTP/1.1\r\n"));
|
||||
assert!(
|
||||
request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer launch-secret\r\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_worker_posts_frontend_contract_to_workspace_path() {
|
||||
let (base_url, server) = serve_json_once(serde_json::json!({
|
||||
"workspace_id": "workspace-1",
|
||||
"runtime_id": "embedded",
|
||||
"worker_id": "worker-1",
|
||||
"console_href": "/w/workspace-1/workers/embedded/worker-1",
|
||||
"worker": {
|
||||
"runtime_id": "embedded",
|
||||
"worker_id": "worker-1",
|
||||
"host_id": "host-1",
|
||||
"display_name": "Coder one",
|
||||
"label": "Coder one",
|
||||
"profile": "builtin:coder",
|
||||
"singleton_key": null,
|
||||
"tags": [],
|
||||
"workspace": {
|
||||
"visibility": "workspace",
|
||||
"identity": "workspace",
|
||||
"workspace_id": "workspace-1"
|
||||
},
|
||||
"state": "idle",
|
||||
"last_seen_at": null,
|
||||
"pinned": false,
|
||||
"retention_state": "resident",
|
||||
"implementation": {"kind": "embedded", "display_hint": "Embedded"},
|
||||
"capabilities": {"can_stop": true, "can_spawn_followup": false},
|
||||
"diagnostics": []
|
||||
},
|
||||
"diagnostics": []
|
||||
}))
|
||||
.await;
|
||||
let target = BackendWorkerLaunchTarget::new(&base_url, Some("workspace-1".to_string()));
|
||||
let api = BackendApiClient::from_access_token_for_test(&base_url, "create-secret").unwrap();
|
||||
let create = BackendCreateWorkerRequest {
|
||||
runtime_id: "embedded".to_string(),
|
||||
display_name: "Coder one".to_string(),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory: None,
|
||||
control_operation_id: None,
|
||||
};
|
||||
|
||||
let response = create_backend_worker_with_client(&target, &create, &api)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.worker_id, "worker-1");
|
||||
let request = server.await.unwrap();
|
||||
assert!(request.starts_with("POST /api/w/workspace-1/workers HTTP/1.1\r\n"));
|
||||
assert!(
|
||||
request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer create-secret\r\n")
|
||||
);
|
||||
let body = request.split_once("\r\n\r\n").unwrap().1;
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(body["runtime_id"], "embedded");
|
||||
assert_eq!(body["display_name"], "Coder one");
|
||||
assert_eq!(body["profile"], "builtin:coder");
|
||||
assert_eq!(body["initial_submit"], serde_json::json!([]));
|
||||
assert_eq!(body["working_directory"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_url_uses_backend_runtime_worker_identity() {
|
||||
@@ -631,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()),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -112,26 +112,27 @@ mod tests {
|
||||
async fn encodes_methods_and_decodes_events_above_transport() {
|
||||
let mut socket = TestSocket::default();
|
||||
socket.incoming.push_back(
|
||||
encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
encode_event(&Event::WorkerState {
|
||||
snapshot: WorkerStatus::Idle.into(),
|
||||
})
|
||||
.expect("encode event"),
|
||||
);
|
||||
let mut client = Client::new(socket);
|
||||
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.send(&Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"hello",
|
||||
))
|
||||
.await
|
||||
.expect("send method");
|
||||
assert!(matches!(
|
||||
decode_method(&client.socket.sent[0]),
|
||||
Ok(Method::Run { .. })
|
||||
Ok(Method::Submit { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
client.next_event().await,
|
||||
Ok(Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
}))
|
||||
Ok(Some(Event::WorkerState { .. }))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -21,23 +21,29 @@ pub use backend_auth::{
|
||||
poll_device_login, start_device_login, wait_for_device_login,
|
||||
};
|
||||
pub use backend_runtime::{
|
||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError,
|
||||
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
||||
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
|
||||
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
||||
BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic,
|
||||
BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
|
||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
||||
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::{
|
||||
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent,
|
||||
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
|
||||
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||
BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
|
||||
StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
|
||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||
};
|
||||
pub use workspace_api::{
|
||||
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
|
||||
BackendRuntimeTarget,
|
||||
BackendRuntimeTarget, BackendWorkerLaunchTarget,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -123,6 +123,11 @@ pub struct Dashboard {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendWorkerLaunch {
|
||||
pub target: BackendWorkerLaunchTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerList {
|
||||
pub backend_target: BackendRuntimeListTarget,
|
||||
@@ -199,6 +204,13 @@ pub trait Target: fmt::Debug + Send + Sync {
|
||||
Err(TargetError::unsupported("Worker dashboard", self.kind()))
|
||||
}
|
||||
|
||||
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"Backend Worker launch",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
Err(TargetError::unsupported("Worker listing", self.kind()))
|
||||
}
|
||||
@@ -299,6 +311,15 @@ impl Target for BackendTarget {
|
||||
})
|
||||
}
|
||||
|
||||
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
|
||||
Ok(BackendWorkerLaunch {
|
||||
target: BackendWorkerLaunchTarget::new(
|
||||
self.base_url.clone(),
|
||||
self.workspace_id.clone(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
Ok(WorkerList {
|
||||
backend_target: BackendRuntimeListTarget::new(
|
||||
|
||||
@@ -89,17 +89,20 @@ mod tests {
|
||||
let mut client = Client::new(socket);
|
||||
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.send(&Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"hello",
|
||||
))
|
||||
.await
|
||||
.expect("send method");
|
||||
assert!(matches!(
|
||||
peer.next().await.as_deref().map(decode_method),
|
||||
Some(Ok(Method::Run { .. }))
|
||||
Some(Ok(Method::Submit { .. }))
|
||||
));
|
||||
|
||||
peer.send(
|
||||
encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
encode_event(&Event::WorkerState {
|
||||
snapshot: WorkerStatus::Idle.into(),
|
||||
})
|
||||
.expect("encode event"),
|
||||
)
|
||||
@@ -107,9 +110,7 @@ mod tests {
|
||||
.expect("send event");
|
||||
assert!(matches!(
|
||||
client.next_event().await,
|
||||
Ok(Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
}))
|
||||
Ok(Some(Event::WorkerState { .. }))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ mod tests {
|
||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let event = encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
let event = encode_event(&Event::WorkerState {
|
||||
snapshot: WorkerStatus::Idle.into(),
|
||||
})
|
||||
.unwrap();
|
||||
stream.write_all(event.as_bytes()).await.unwrap();
|
||||
@@ -126,12 +126,7 @@ mod tests {
|
||||
.await
|
||||
.expect("client should receive event while alive")
|
||||
.expect("transport should succeed");
|
||||
assert!(matches!(
|
||||
event,
|
||||
Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
})
|
||||
));
|
||||
assert!(matches!(event, Some(Event::WorkerState { .. })));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
@@ -147,12 +142,18 @@ mod tests {
|
||||
|
||||
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.send(&Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"hello",
|
||||
))
|
||||
.await
|
||||
.expect("send method");
|
||||
|
||||
let received = server.await.unwrap().expect("method message");
|
||||
assert!(matches!(decode_method(&received), Ok(Method::Run { .. })));
|
||||
assert!(matches!(
|
||||
decode_method(&received),
|
||||
Ok(Method::Submit { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -114,10 +114,10 @@ mod tests {
|
||||
assert!(matches!(
|
||||
message,
|
||||
Message::Text(ref text)
|
||||
if matches!(decode_method(text), Ok(Method::Run { .. }))
|
||||
if matches!(decode_method(text), Ok(Method::Submit { .. }))
|
||||
));
|
||||
let event = encode_event(&Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
let event = encode_event(&Event::WorkerState {
|
||||
snapshot: WorkerStatus::Idle.into(),
|
||||
})
|
||||
.unwrap();
|
||||
socket.send(Message::Text(event.into())).await.unwrap();
|
||||
@@ -126,14 +126,15 @@ mod tests {
|
||||
let request = format!("ws://{address}").into_client_request().unwrap();
|
||||
let mut client = Client::new(Socket::connect(request).await.unwrap());
|
||||
client
|
||||
.send(&Method::run_text("hello"))
|
||||
.send(&Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"hello",
|
||||
))
|
||||
.await
|
||||
.expect("send method");
|
||||
assert!(matches!(
|
||||
client.next_event().await,
|
||||
Ok(Some(Event::Status {
|
||||
status: WorkerStatus::Idle
|
||||
}))
|
||||
Ok(Some(Event::WorkerState { .. }))
|
||||
));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ use workspace_api::{
|
||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
|
||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
};
|
||||
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
@@ -241,6 +243,41 @@ impl BackendWorkspaceProductClient {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_runtimes(
|
||||
&self,
|
||||
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
|
||||
self.get_json("/runtimes")
|
||||
}
|
||||
|
||||
pub fn runtime_detail(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
|
||||
}
|
||||
|
||||
pub fn reveal_runtime_trust_key(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
) -> Result<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
|
||||
self.get_json(&format!(
|
||||
"/runtimes/{}/trust-key",
|
||||
encode_path_segment(runtime_id)
|
||||
))
|
||||
}
|
||||
|
||||
pub fn revoke_runtime_trust_key(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: &RevokeRuntimeTrustKeyRequest,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::DELETE,
|
||||
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||
Some(request),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
||||
self.get_json("/memory")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+187
-101
@@ -15,13 +15,13 @@ 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, MemoryConfig, MemoryFeatureConfig,
|
||||
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
|
||||
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
||||
WorkerManifest, WorkerMeta,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
|
||||
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
||||
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
||||
};
|
||||
|
||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||
@@ -54,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)]
|
||||
@@ -67,15 +63,13 @@ pub struct WorkerManifestConfig {
|
||||
/// First-class web tool opt-in. See [`WebConfig`].
|
||||
#[serde(default)]
|
||||
pub web: Option<WebConfig>,
|
||||
/// Memory subsystem opt-in. See [`MemoryConfig`].
|
||||
#[serde(default)]
|
||||
pub memory: Option<MemoryConfig>,
|
||||
/// External Agent Skills directories. See [`crate::SkillsConfig`].
|
||||
#[serde(default)]
|
||||
pub skills: Option<SkillsConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub task: Option<FeatureFlagConfigPartial>,
|
||||
@@ -103,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 {
|
||||
@@ -147,7 +139,6 @@ impl FeatureConfigPartial {
|
||||
other.orchestration,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,18 +184,86 @@ impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemoryFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub staging: Option<bool>,
|
||||
pub staging_tools: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub resident: Option<MemoryResidentProfileConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub consolidation: Option<MemoryConsolidationProfileConfigPartial>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemoryResidentProfileConfigPartial {
|
||||
#[serde(default)]
|
||||
pub inject_summary: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemoryExtractionProfileConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub model: Option<ModelManifest>,
|
||||
#[serde(default)]
|
||||
pub threshold: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub worker_max_turns: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemoryConsolidationProfileConfigPartial {
|
||||
#[serde(default)]
|
||||
pub request_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl MemoryFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
staging: other.staging.or(self.staging),
|
||||
staging_tools: other.staging_tools.or(self.staging_tools),
|
||||
resident: merge_option(
|
||||
self.resident,
|
||||
other.resident,
|
||||
MemoryResidentProfileConfigPartial::merge,
|
||||
),
|
||||
extraction: merge_option(
|
||||
self.extraction,
|
||||
other.extraction,
|
||||
MemoryExtractionProfileConfigPartial::merge,
|
||||
),
|
||||
consolidation: merge_option(
|
||||
self.consolidation,
|
||||
other.consolidation,
|
||||
MemoryConsolidationProfileConfigPartial::merge,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryResidentProfileConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
inject_summary: other.inject_summary.or(self.inject_summary),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryExtractionProfileConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
model: other.model.or(self.model),
|
||||
threshold: other.threshold.or(self.threshold),
|
||||
worker_max_turns: other.worker_max_turns.or(self.worker_max_turns),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,13 +312,21 @@ impl MergeRequestFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryConsolidationProfileConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
request_enabled: other.request_enabled.or(self.request_enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
fn from(value: FeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
memory: value
|
||||
.memory
|
||||
.map(MemoryFeatureConfig::from)
|
||||
.map(ResolvedMemoryFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
@@ -296,10 +363,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.orchestration
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
plugins: value
|
||||
.plugins
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,20 +392,52 @@ impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
|
||||
impl From<MemoryFeatureConfigPartial> for ResolvedMemoryFeatureConfig {
|
||||
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
||||
let resident = value.resident.unwrap_or_default();
|
||||
let extraction = value.extraction.unwrap_or_default();
|
||||
let consolidation = value.consolidation.unwrap_or_default();
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
staging: value.staging.unwrap_or_default(),
|
||||
profile: MemoryFeatureProfileConfig {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
staging_tools: value.staging_tools.unwrap_or_default(),
|
||||
resident: MemoryResidentProfileConfig {
|
||||
inject_summary: resident.inject_summary.unwrap_or(true),
|
||||
},
|
||||
extraction: MemoryExtractionProfileConfig {
|
||||
enabled: extraction.enabled.unwrap_or(true),
|
||||
model: extraction.model,
|
||||
threshold: extraction.threshold.or(Some(50_000)),
|
||||
worker_max_turns: extraction
|
||||
.worker_max_turns
|
||||
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
||||
},
|
||||
consolidation: MemoryConsolidationProfileConfig {
|
||||
request_enabled: consolidation.request_enabled.unwrap_or(true),
|
||||
},
|
||||
},
|
||||
workspace_settings: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
||||
fn from(value: MemoryFeatureConfig) -> Self {
|
||||
impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
||||
fn from(value: ResolvedMemoryFeatureConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.enabled),
|
||||
staging: Some(value.staging),
|
||||
enabled: Some(value.profile.enabled),
|
||||
staging_tools: Some(value.profile.staging_tools),
|
||||
resident: Some(MemoryResidentProfileConfigPartial {
|
||||
inject_summary: Some(value.profile.resident.inject_summary),
|
||||
}),
|
||||
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||
enabled: Some(value.profile.extraction.enabled),
|
||||
model: value.profile.extraction.model,
|
||||
threshold: value.profile.extraction.threshold,
|
||||
worker_max_turns: value.profile.extraction.worker_max_turns,
|
||||
}),
|
||||
consolidation: Some(MemoryConsolidationProfileConfigPartial {
|
||||
request_enabled: Some(value.profile.consolidation.request_enabled),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -543,13 +637,23 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
||||
(removed; use compaction.prune_protected_tokens)",
|
||||
));
|
||||
}
|
||||
if value.get("memory").is_some() {
|
||||
return Err(toml::de::Error::custom(
|
||||
"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("memory")
|
||||
.get("feature")
|
||||
.and_then(toml::Value::as_table)
|
||||
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
|
||||
.is_some_and(|table| table.contains_key("plugins"))
|
||||
{
|
||||
return Err(toml::de::Error::custom(
|
||||
"unknown field in manifest: memory.extract_worker_max_input_tokens (removed)",
|
||||
"unknown field in manifest: feature.plugins (dynamic Plugins are not supported)",
|
||||
));
|
||||
}
|
||||
if value
|
||||
@@ -633,11 +737,6 @@ impl WorkerManifestConfig {
|
||||
for rule in &mut self.delegation_scope.deny {
|
||||
rule.target = join_if_relative(base, &rule.target);
|
||||
}
|
||||
if let Some(ref mut memory) = self.memory
|
||||
&& let Some(ref mut root) = memory.workspace_root
|
||||
{
|
||||
*root = join_if_relative(base, root);
|
||||
}
|
||||
if let Some(ref mut compaction) = self.compaction
|
||||
&& let Some(ref mut cp) = compaction.model
|
||||
{
|
||||
@@ -674,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,
|
||||
@@ -682,7 +780,6 @@ impl WorkerManifestConfig {
|
||||
CompactionConfigPartial::merge,
|
||||
),
|
||||
web: merge_option(self.web, upper.web, WebConfig::merge),
|
||||
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
|
||||
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
|
||||
}
|
||||
}
|
||||
@@ -695,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
|
||||
@@ -754,32 +841,6 @@ impl crate::WebFetchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
workspace_root: upper.workspace_root.or(self.workspace_root),
|
||||
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
|
||||
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
|
||||
inject_summary: upper.inject_summary.or(self.inject_summary),
|
||||
workspace_id: upper.workspace_id.or(self.workspace_id),
|
||||
settings_revision: upper.settings_revision.or(self.settings_revision),
|
||||
language: upper.language.or(self.language),
|
||||
extract_model: upper.extract_model.or(self.extract_model),
|
||||
extract_threshold: upper.extract_threshold.or(self.extract_threshold),
|
||||
extract_worker_max_turns: upper
|
||||
.extract_worker_max_turns
|
||||
.or(self.extract_worker_max_turns),
|
||||
consolidation_model: upper.consolidation_model.or(self.consolidation_model),
|
||||
consolidation_threshold_files: upper
|
||||
.consolidation_threshold_files
|
||||
.or(self.consolidation_threshold_files),
|
||||
consolidation_threshold_bytes: upper
|
||||
.consolidation_threshold_bytes
|
||||
.or(self.consolidation_threshold_bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkerMetaConfig {
|
||||
fn merge(self, upper: Self) -> Self {
|
||||
Self {
|
||||
@@ -1219,11 +1280,9 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
session,
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
plugins: cfg.plugins,
|
||||
mcp: cfg.mcp,
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
memory: cfg.memory,
|
||||
skills: cfg.skills,
|
||||
profile: None,
|
||||
})
|
||||
@@ -1260,18 +1319,17 @@ 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,
|
||||
web: None,
|
||||
memory: None,
|
||||
skills: None,
|
||||
}
|
||||
}
|
||||
@@ -1507,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"));
|
||||
@@ -1644,6 +1703,7 @@ mod tests {
|
||||
target: abs("/a"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
@@ -1655,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()
|
||||
@@ -1846,29 +1908,50 @@ prune_protected_turns = 3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() {
|
||||
let bad = r#"
|
||||
[memory]
|
||||
extract_worker_max_input_tokens = 30000
|
||||
"#;
|
||||
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("memory.extract_worker_max_input_tokens"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[feature.memory]
|
||||
enabled = true
|
||||
staging_tools = false
|
||||
|
||||
[feature.memory.resident]
|
||||
inject_summary = false
|
||||
|
||||
[feature.memory.extraction]
|
||||
enabled = true
|
||||
threshold = 42000
|
||||
worker_max_turns = 2
|
||||
|
||||
[feature.memory.consolidation]
|
||||
request_enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let memory = cfg.feature.memory.unwrap();
|
||||
assert_eq!(memory.enabled, Some(true));
|
||||
assert_eq!(memory.staging_tools, Some(false));
|
||||
assert_eq!(memory.resident.unwrap().inject_summary, Some(false));
|
||||
assert_eq!(memory.consolidation.unwrap().request_enabled, Some(false));
|
||||
let extraction = memory.extraction.unwrap();
|
||||
assert_eq!(extraction.enabled, Some(true));
|
||||
assert_eq!(extraction.threshold, Some(42_000));
|
||||
assert_eq!(extraction.worker_max_turns, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_accepts_extract_worker_max_turns() {
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
fn from_toml_rejects_legacy_top_level_memory_authority() {
|
||||
let err = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[memory]
|
||||
extract_worker_max_turns = 2
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2));
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("memory"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1948,7 +2031,7 @@ worker_max_turns = 7
|
||||
fn feature_flags_default_disabled_in_resolved_manifest() {
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
assert!(!manifest.feature.task.enabled);
|
||||
assert!(!manifest.feature.memory.enabled);
|
||||
assert!(!manifest.feature.memory.profile.enabled);
|
||||
assert!(!manifest.feature.web.enabled);
|
||||
assert!(!manifest.feature.sub_worker.enabled);
|
||||
assert!(!manifest.feature.objective.enabled);
|
||||
@@ -2002,6 +2085,7 @@ enabled = false
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
@@ -2025,8 +2109,8 @@ enabled = false
|
||||
}
|
||||
);
|
||||
assert!(!manifest.feature.orchestration.enabled);
|
||||
assert!(!manifest.feature.memory.enabled);
|
||||
assert!(!manifest.feature.memory.staging);
|
||||
assert!(!manifest.feature.memory.profile.enabled);
|
||||
assert!(!manifest.feature.memory.profile.staging_tools);
|
||||
assert!(!manifest.feature.objective.enabled);
|
||||
}
|
||||
|
||||
@@ -2074,7 +2158,7 @@ readiness_check = true
|
||||
enabled = true
|
||||
|
||||
[feature.memory]
|
||||
staging = true
|
||||
staging_tools = true
|
||||
|
||||
[feature.manage_workdir]
|
||||
enabled = true
|
||||
@@ -2104,6 +2188,7 @@ enabled = true
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
@@ -2111,8 +2196,8 @@ enabled = true
|
||||
})
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert!(manifest.feature.memory.enabled);
|
||||
assert!(manifest.feature.memory.staging);
|
||||
assert!(manifest.feature.memory.profile.enabled);
|
||||
assert!(manifest.feature.memory.profile.staging_tools);
|
||||
assert!(manifest.feature.manage_workdir.enabled);
|
||||
assert!(manifest.feature.ticket.enabled);
|
||||
assert!(!manifest.feature.ticket.authoring);
|
||||
@@ -2180,6 +2265,7 @@ permission = "write"
|
||||
target: abs("/worker"),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
|
||||
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
||||
|
||||
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
|
||||
/// See [`crate::MemoryConfig::extract_worker_max_turns`].
|
||||
/// See [`crate::MemoryExtractionProfileConfig::worker_max_turns`].
|
||||
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
||||
|
||||
+666
-177
@@ -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};
|
||||
@@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize};
|
||||
/// part of the manifest — it is the process's `std::env::current_dir()`
|
||||
/// at construction time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerManifest {
|
||||
pub worker: WorkerMeta,
|
||||
pub model: ModelManifest,
|
||||
@@ -68,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
|
||||
@@ -80,11 +77,6 @@ pub struct WorkerManifest {
|
||||
pub mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// Memory subsystem configuration. Presence of `[memory]` configures memory
|
||||
/// storage, extraction, consolidation, and resident injection, but memory
|
||||
/// tools are surfaced only when `[feature.memory].enabled = true`.
|
||||
#[serde(default)]
|
||||
pub memory: Option<MemoryConfig>,
|
||||
/// First-class web tools configuration. Network access remains fail-closed
|
||||
/// under this config; WebSearch/WebFetch schemas are surfaced only when
|
||||
/// `[feature.web].enabled = true`.
|
||||
@@ -109,12 +101,13 @@ pub struct WorkerManifest {
|
||||
/// profile/config data only: they do not carry runtime Worker names, sockets,
|
||||
/// 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, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FeatureConfig {
|
||||
#[serde(default)]
|
||||
pub task: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub memory: MemoryFeatureConfig,
|
||||
pub memory: ResolvedMemoryFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub web: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
@@ -139,15 +132,13 @@ pub struct FeatureConfig {
|
||||
pub merge_request: MergeRequestFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub orchestration: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: FeatureFlagConfig,
|
||||
}
|
||||
|
||||
impl Default for FeatureConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
task: FeatureFlagConfig::disabled(),
|
||||
memory: MemoryFeatureConfig::disabled(),
|
||||
memory: ResolvedMemoryFeatureConfig::default(),
|
||||
web: FeatureFlagConfig::disabled(),
|
||||
image: FeatureFlagConfig::disabled(),
|
||||
sub_worker: FeatureFlagConfig::disabled(),
|
||||
@@ -159,7 +150,6 @@ impl Default for FeatureConfig {
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
merge_request: MergeRequestFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
plugins: FeatureFlagConfig::disabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,34 +212,139 @@ const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MemoryFeatureConfig {
|
||||
#[serde(default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MemoryFeatureProfileConfig {
|
||||
pub enabled: bool,
|
||||
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
|
||||
#[serde(default)]
|
||||
pub staging: bool,
|
||||
pub staging_tools: bool,
|
||||
pub resident: MemoryResidentProfileConfig,
|
||||
pub extraction: MemoryExtractionProfileConfig,
|
||||
pub consolidation: MemoryConsolidationProfileConfig,
|
||||
}
|
||||
|
||||
impl MemoryFeatureConfig {
|
||||
pub const fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
staging: false,
|
||||
}
|
||||
impl MemoryFeatureProfileConfig {
|
||||
pub fn disabled() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub const fn enabled() -> Self {
|
||||
pub fn enabled() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
staging: false,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemoryFeatureConfig {
|
||||
impl Default for MemoryFeatureProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self::disabled()
|
||||
Self {
|
||||
enabled: false,
|
||||
staging_tools: false,
|
||||
resident: MemoryResidentProfileConfig::default(),
|
||||
extraction: MemoryExtractionProfileConfig::default(),
|
||||
consolidation: MemoryConsolidationProfileConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MemoryResidentProfileConfig {
|
||||
pub inject_summary: bool,
|
||||
}
|
||||
|
||||
impl Default for MemoryResidentProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inject_summary: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MemoryExtractionProfileConfig {
|
||||
pub enabled: bool,
|
||||
pub model: Option<ModelManifest>,
|
||||
pub threshold: Option<u64>,
|
||||
pub worker_max_turns: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for MemoryExtractionProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
model: None,
|
||||
threshold: Some(50_000),
|
||||
worker_max_turns: defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MemoryConsolidationProfileConfig {
|
||||
pub request_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for MemoryConsolidationProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
request_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable Memory execution configuration persisted in a resolved Worker Manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ResolvedMemoryFeatureConfig {
|
||||
pub profile: MemoryFeatureProfileConfig,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
|
||||
}
|
||||
|
||||
impl ResolvedMemoryFeatureConfig {
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.profile.enabled
|
||||
}
|
||||
|
||||
pub fn bind_workspace_settings(
|
||||
&mut self,
|
||||
settings: WorkspaceMemorySettingsSnapshot,
|
||||
) -> Result<(), &'static str> {
|
||||
if !self.profile.enabled {
|
||||
if self.workspace_settings.is_some() {
|
||||
return Err("disabled Memory feature must not carry Workspace settings");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.workspace_settings.is_some() {
|
||||
return Err("memory Workspace settings are already bound");
|
||||
}
|
||||
self.workspace_settings = Some(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
||||
self.workspace_settings.clone()
|
||||
}
|
||||
|
||||
pub fn validate_execution(&self) -> Result<(), &'static str> {
|
||||
if self.profile.enabled && self.workspace_settings.is_none() {
|
||||
return Err("enabled Memory feature requires trusted Workspace settings");
|
||||
}
|
||||
if !self.profile.enabled && self.workspace_settings.is_some() {
|
||||
return Err("disabled Memory feature must not carry Workspace settings");
|
||||
}
|
||||
if let Some(settings) = &self.workspace_settings
|
||||
&& (settings.settings_revision == 0
|
||||
|| !is_normalized_workspace_memory_language(&settings.language))
|
||||
{
|
||||
return Err("Memory Workspace settings snapshot metadata is invalid");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,98 +579,6 @@ pub struct WorkspaceMemorySettingsSnapshot {
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// Memory subsystem configuration. Presence in the manifest enables
|
||||
/// memory; `workspace_root` pins the memory workspace explicitly. When it
|
||||
/// is absent, memory resolution searches upward from the Worker's pwd for a
|
||||
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
|
||||
/// as a memory root.
|
||||
///
|
||||
/// All fields are `Option`; defaults are applied at the consumer
|
||||
/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple
|
||||
/// (`upper.x.or(self.x)`) without a separate partial/resolved split.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MemoryConfig {
|
||||
/// Override for the memory workspace root. When `None`, consumers resolve
|
||||
/// the root from their default path and ancestor `.yoi/memory` markers.
|
||||
/// When set, must be an absolute path.
|
||||
#[serde(default)]
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
/// Maximum number of records returned by `MemoryQuery` /
|
||||
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
|
||||
#[serde(default)]
|
||||
pub query_result_limit: Option<usize>,
|
||||
/// Lines of context before and after each match in query excerpts.
|
||||
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
|
||||
#[serde(default)]
|
||||
pub query_excerpt_lines: Option<usize>,
|
||||
/// Whether the body of `memory/summary.md` is exposed in the resident
|
||||
/// system-prompt section. `None` ⇒ enabled.
|
||||
#[serde(default)]
|
||||
pub inject_summary: Option<bool>,
|
||||
/// Workspace that owns the bound Memory settings revision.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
/// Monotonic revision of the bound Workspace Memory settings.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub settings_revision: Option<u64>,
|
||||
/// Language from the bound Workspace Memory settings revision.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
/// Optional model for the extract worker. When `None`,
|
||||
/// the main engine model is cloned via `clone_boxed()`. Lightweight
|
||||
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
|
||||
/// recommended.
|
||||
#[serde(default)]
|
||||
pub extract_model: Option<ModelManifest>,
|
||||
/// Cumulative input-token threshold (since the last extract pointer)
|
||||
/// that triggers an extract run. `None` disables the extract trigger
|
||||
/// entirely; memory tools and resident injection still work, only
|
||||
/// the auto-extract trigger is dormant.
|
||||
#[serde(default)]
|
||||
pub extract_threshold: Option<u64>,
|
||||
/// Optional maximum extract-worker tool-loop depth. `None` leaves
|
||||
/// the worker unlimited; the default bounds runaway short-context
|
||||
/// loops. Falls through to
|
||||
/// [`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`] when unset.
|
||||
#[serde(default)]
|
||||
pub extract_worker_max_turns: Option<u32>,
|
||||
/// Optional model for the consolidation worker. When
|
||||
/// `None`, the main engine model is cloned via `clone_boxed()`.
|
||||
/// Reasoning-class models are recommended.
|
||||
#[serde(default)]
|
||||
pub consolidation_model: Option<ModelManifest>,
|
||||
/// Consolidation trigger: file-count threshold of `_staging/`. The
|
||||
/// consolidation run fires when the staging directory has at least
|
||||
/// this many entries. Either threshold reaching its limit fires
|
||||
/// consolidation (logical OR). `None` for both thresholds ⇒
|
||||
/// consolidation disabled.
|
||||
#[serde(default)]
|
||||
pub consolidation_threshold_files: Option<usize>,
|
||||
/// Consolidation trigger: byte-size threshold across all `_staging/`
|
||||
/// entries. Either threshold reaching its limit fires consolidation.
|
||||
/// `None` for both thresholds ⇒ consolidation disabled.
|
||||
#[serde(default)]
|
||||
pub consolidation_threshold_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl MemoryConfig {
|
||||
/// Replace any untrusted manifest values with a trusted Workspace snapshot.
|
||||
pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) {
|
||||
self.workspace_id = Some(snapshot.workspace_id.clone());
|
||||
self.settings_revision = Some(snapshot.settings_revision);
|
||||
self.language = Some(snapshot.language.clone());
|
||||
}
|
||||
|
||||
/// Return the complete bound Workspace settings snapshot, if every field is present.
|
||||
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
||||
Some(WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: self.workspace_id.clone()?,
|
||||
settings_revision: self.settings_revision?,
|
||||
language: self.language.clone()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerMeta {
|
||||
@@ -931,6 +934,10 @@ impl Default for CompactionConfig {
|
||||
}
|
||||
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|
||||
}
|
||||
|
||||
/// Parse a manifest from a TOML string.
|
||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||
config::reject_removed_manifest_fields(s)?;
|
||||
@@ -941,6 +948,267 @@ impl WorkerManifest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct LegacyMemoryFeatureConfig {
|
||||
enabled: bool,
|
||||
staging: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct LegacyMemoryConfig {
|
||||
#[serde(rename = "workspace_root")]
|
||||
_workspace_root: Option<PathBuf>,
|
||||
#[serde(rename = "query_result_limit")]
|
||||
_query_result_limit: Option<usize>,
|
||||
#[serde(rename = "query_excerpt_lines")]
|
||||
_query_excerpt_lines: Option<usize>,
|
||||
inject_summary: Option<bool>,
|
||||
workspace_id: Option<String>,
|
||||
settings_revision: Option<u64>,
|
||||
language: Option<String>,
|
||||
extract_model: Option<ModelManifest>,
|
||||
extract_threshold: Option<u64>,
|
||||
extract_worker_max_turns: Option<u32>,
|
||||
consolidation_model: Option<ModelManifest>,
|
||||
consolidation_threshold_files: Option<usize>,
|
||||
consolidation_threshold_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
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(
|
||||
manifest: &WorkerManifest,
|
||||
) -> Result<serde_json::Value, serde_json::Error> {
|
||||
Ok(serde_json::json!({
|
||||
"schema_version": RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION,
|
||||
"manifest": serde_json::to_value(manifest)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read a durable resolved Worker Manifest through the versioned compatibility
|
||||
/// boundary. Runtime code must not deserialize persisted snapshots directly.
|
||||
pub fn read_persisted_worker_manifest_snapshot(
|
||||
snapshot: serde_json::Value,
|
||||
) -> Result<WorkerManifest, serde_json::Error> {
|
||||
let object = snapshot.as_object().ok_or_else(|| {
|
||||
serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"resolved Worker manifest snapshot must be an object",
|
||||
))
|
||||
})?;
|
||||
if let Some(version) = object.get("schema_version") {
|
||||
let version = version.as_u64().ok_or_else(|| {
|
||||
serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"resolved Worker manifest snapshot schema_version must be an integer",
|
||||
))
|
||||
})?;
|
||||
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}"),
|
||||
)));
|
||||
}
|
||||
if object.len() != 2 {
|
||||
return Err(serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"resolved Worker manifest snapshot contains unknown fields",
|
||||
)));
|
||||
}
|
||||
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",
|
||||
))
|
||||
})?;
|
||||
if manifest
|
||||
.as_object()
|
||||
.is_some_and(|manifest| manifest.contains_key("memory"))
|
||||
{
|
||||
return Err(serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"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)?);
|
||||
}
|
||||
|
||||
migrate_legacy_resolved_manifest_snapshot(snapshot)
|
||||
}
|
||||
|
||||
fn validate_persisted_worker_manifest(
|
||||
manifest: WorkerManifest,
|
||||
) -> Result<WorkerManifest, serde_json::Error> {
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.validate_execution()
|
||||
.map_err(|message| {
|
||||
serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
message,
|
||||
))
|
||||
})?;
|
||||
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> {
|
||||
let root = snapshot.as_object_mut().ok_or_else(|| {
|
||||
serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"legacy resolved Worker manifest snapshot must be an object",
|
||||
))
|
||||
})?;
|
||||
let legacy_memory = root.remove("memory");
|
||||
let feature = root
|
||||
.entry("feature")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| {
|
||||
serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"legacy resolved Worker manifest feature must be an object",
|
||||
))
|
||||
})?;
|
||||
let legacy_feature_memory: LegacyMemoryFeatureConfig = serde_json::from_value(
|
||||
feature
|
||||
.remove("memory")
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
)?;
|
||||
let requested_enabled = legacy_feature_memory.enabled;
|
||||
let staging_tools = legacy_feature_memory.staging;
|
||||
|
||||
let legacy_memory: LegacyMemoryConfig =
|
||||
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
|
||||
let mut workspace_settings = match (
|
||||
legacy_memory.workspace_id,
|
||||
legacy_memory.settings_revision,
|
||||
legacy_memory.language,
|
||||
) {
|
||||
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
|
||||
"workspace_id": workspace_id,
|
||||
"settings_revision": settings_revision,
|
||||
"language": language,
|
||||
})),
|
||||
(None, None, None) => None,
|
||||
_ => {
|
||||
return Err(serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"legacy resolved Worker manifest contains a partial Memory settings snapshot",
|
||||
)));
|
||||
}
|
||||
};
|
||||
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(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"legacy resolved Worker manifest uses a Worker-owned consolidation model that cannot be migrated to Backend authority",
|
||||
)));
|
||||
}
|
||||
let consolidation_enabled = match (
|
||||
legacy_memory.consolidation_threshold_files,
|
||||
legacy_memory.consolidation_threshold_bytes,
|
||||
) {
|
||||
(None, None) => false,
|
||||
(Some(5), Some(50_000)) => true,
|
||||
_ => {
|
||||
return Err(serde_json::Error::io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"legacy resolved Worker manifest uses custom consolidation thresholds that cannot be migrated to Backend policy",
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut resolved = serde_json::json!({
|
||||
"profile": {
|
||||
"enabled": enabled,
|
||||
"staging_tools": staging_tools,
|
||||
"resident": {
|
||||
"inject_summary": legacy_memory.inject_summary.unwrap_or(true),
|
||||
},
|
||||
"extraction": {
|
||||
"enabled": extraction_enabled,
|
||||
"model": serde_json::to_value(legacy_memory.extract_model)?,
|
||||
"threshold": legacy_memory.extract_threshold,
|
||||
"worker_max_turns": legacy_memory.extract_worker_max_turns,
|
||||
},
|
||||
"consolidation": {
|
||||
"request_enabled": consolidation_enabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
if let Some(workspace_settings) = workspace_settings {
|
||||
resolved
|
||||
.as_object_mut()
|
||||
.expect("resolved Memory config is an object")
|
||||
.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)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1101,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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1246,36 +1542,237 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_memory_is_none() {
|
||||
fn omitted_memory_feature_is_disabled() {
|
||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.memory.is_none());
|
||||
assert!(!manifest.feature.memory.profile.enabled);
|
||||
assert!(manifest.feature.memory.workspace_settings.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_memory_section_enables_with_default_root() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
|
||||
fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
|
||||
let toml = format!(
|
||||
"{MINIMAL_REQUIRED}\n\
|
||||
[feature.memory.profile]\n\
|
||||
enabled = true\n\
|
||||
staging_tools = false\n\n\
|
||||
[feature.memory.profile.resident]\n\
|
||||
inject_summary = false\n\n\
|
||||
[feature.memory.profile.extraction]\n\
|
||||
enabled = true\n\
|
||||
threshold = 42000\n\
|
||||
worker_max_turns = 2\n\n\
|
||||
[feature.memory.workspace_settings]\n\
|
||||
workspace_id = \"workspace-1\"\n\
|
||||
settings_revision = 7\n\
|
||||
language = \"日本語\"\n"
|
||||
);
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.expect("memory section parsed");
|
||||
assert!(mem.workspace_root.is_none());
|
||||
assert_eq!(mem.inject_summary, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_section_with_inject_summary_false() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.unwrap();
|
||||
assert_eq!(mem.inject_summary, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_section_with_explicit_root() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.unwrap();
|
||||
assert!(manifest.feature.memory.profile.enabled);
|
||||
assert!(!manifest.feature.memory.profile.resident.inject_summary);
|
||||
assert_eq!(
|
||||
mem.workspace_root.unwrap(),
|
||||
std::path::PathBuf::from("/some/where")
|
||||
manifest.feature.memory.profile.extraction.threshold,
|
||||
Some(42_000)
|
||||
);
|
||||
assert_eq!(
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.workspace_settings()
|
||||
.unwrap()
|
||||
.language,
|
||||
"日本語"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_memory_execution_validation_fails_closed() {
|
||||
let snapshot = WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
let mut enabled = ResolvedMemoryFeatureConfig::default();
|
||||
enabled.profile.enabled = true;
|
||||
assert!(enabled.validate_execution().is_err());
|
||||
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
|
||||
assert!(enabled.validate_execution().is_ok());
|
||||
|
||||
let mut disabled = ResolvedMemoryFeatureConfig::default();
|
||||
disabled.workspace_settings = Some(snapshot.clone());
|
||||
assert!(disabled.validate_execution().is_err());
|
||||
assert!(disabled.bind_workspace_settings(snapshot).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||
assert!(WorkerManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
|
||||
let mut manifest =
|
||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||
manifest["feature"]["memory"] = serde_json::json!({
|
||||
"enabled": true,
|
||||
"staging": true,
|
||||
});
|
||||
manifest["memory"] = serde_json::json!({
|
||||
"workspace_root": "/discarded",
|
||||
"query_result_limit": 999,
|
||||
"inject_summary": false,
|
||||
"workspace_id": "workspace-1",
|
||||
"settings_revision": 9,
|
||||
"language": "Français",
|
||||
"extract_threshold": 1234,
|
||||
"extract_worker_max_turns": 3,
|
||||
"consolidation_threshold_files": 5,
|
||||
"consolidation_threshold_bytes": 50000,
|
||||
});
|
||||
|
||||
let migrated = read_persisted_worker_manifest_snapshot(manifest).unwrap();
|
||||
assert!(migrated.feature.memory.profile.enabled);
|
||||
assert!(migrated.feature.memory.profile.staging_tools);
|
||||
assert!(!migrated.feature.memory.profile.resident.inject_summary);
|
||||
assert_eq!(
|
||||
migrated.feature.memory.profile.extraction.threshold,
|
||||
Some(1234)
|
||||
);
|
||||
assert!(
|
||||
migrated
|
||||
.feature
|
||||
.memory
|
||||
.profile
|
||||
.consolidation
|
||||
.request_enabled
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.feature
|
||||
.memory
|
||||
.workspace_settings()
|
||||
.unwrap()
|
||||
.language,
|
||||
"Français"
|
||||
);
|
||||
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
|
||||
assert_eq!(current["schema_version"], 3);
|
||||
assert!(current["manifest"].get("memory").is_none());
|
||||
|
||||
let mut disabled =
|
||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||
disabled["feature"]["memory"] = serde_json::json!({ "enabled": false });
|
||||
disabled["memory"] = serde_json::json!({
|
||||
"workspace_id": "workspace-1",
|
||||
"settings_revision": 9,
|
||||
"language": "Français",
|
||||
});
|
||||
let disabled = read_persisted_worker_manifest_snapshot(disabled).unwrap();
|
||||
assert!(!disabled.feature.memory.profile.enabled);
|
||||
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 =
|
||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||
let mut mixed = manifest.clone();
|
||||
mixed["feature"]["memory"] = serde_json::json!({ "enabled": true, "profile": {} });
|
||||
mixed["memory"] = serde_json::json!({});
|
||||
assert!(read_persisted_worker_manifest_snapshot(mixed).is_err());
|
||||
|
||||
let mut custom_policy =
|
||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||
custom_policy["feature"]["memory"] = serde_json::json!({ "enabled": true });
|
||||
custom_policy["memory"] = serde_json::json!({
|
||||
"workspace_id": "workspace-1",
|
||||
"settings_revision": 1,
|
||||
"language": "English",
|
||||
"consolidation_threshold_files": 99,
|
||||
"consolidation_threshold_bytes": 50000,
|
||||
});
|
||||
assert!(read_persisted_worker_manifest_snapshot(custom_policy).is_err());
|
||||
|
||||
let current = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
let mut current = write_persisted_worker_manifest_snapshot(¤t).unwrap();
|
||||
current["manifest"]["memory"] = serde_json::json!({
|
||||
"workspace_id": "workspace-1",
|
||||
"settings_revision": 1,
|
||||
"language": "English",
|
||||
});
|
||||
assert!(read_persisted_worker_manifest_snapshot(current).is_err());
|
||||
|
||||
let mut missing_settings = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
missing_settings.feature.memory.profile.enabled = true;
|
||||
let missing_settings = write_persisted_worker_manifest_snapshot(&missing_settings).unwrap();
|
||||
assert!(read_persisted_worker_manifest_snapshot(missing_settings).is_err());
|
||||
|
||||
let mut malformed_legacy = manifest.clone();
|
||||
malformed_legacy["feature"]["memory"] = serde_json::json!({ "enabled": "yes" });
|
||||
malformed_legacy["memory"] = serde_json::json!({ "unknown": true });
|
||||
assert!(read_persisted_worker_manifest_snapshot(malformed_legacy).is_err());
|
||||
|
||||
assert!(
|
||||
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
||||
"schema_version": 4,
|
||||
"manifest": manifest,
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1291,14 +1788,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_section_with_language() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||
let mem = manifest.memory.unwrap();
|
||||
assert_eq!(mem.language.as_deref(), Some("Japanese"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unknown_scheme() {
|
||||
let toml =
|
||||
|
||||
+100
-1874
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,10 @@ use crate::config::{
|
||||
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
||||
};
|
||||
use crate::model::{AuthRef, ModelManifest};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
|
||||
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
|
||||
WorkerMetaConfig, paths,
|
||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
|
||||
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
|
||||
paths,
|
||||
};
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -185,7 +182,7 @@ pub fn validate_profile_execution_target(
|
||||
if feature.manage_workdir.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
||||
}
|
||||
if feature.memory.enabled || feature.memory.staging {
|
||||
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
||||
}
|
||||
if feature.merge_request.show
|
||||
@@ -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,11 +632,9 @@ fn resolve_profile_value(
|
||||
session: profile.session,
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
plugins: profile.plugins,
|
||||
mcp: profile.mcp,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
memory: profile.memory.map(Into::into),
|
||||
skills: profile.skills,
|
||||
};
|
||||
let config =
|
||||
@@ -663,51 +655,6 @@ fn resolve_profile_value(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProfileMemoryConfig {
|
||||
#[serde(default)]
|
||||
workspace_root: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
query_result_limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
query_excerpt_lines: Option<usize>,
|
||||
#[serde(default)]
|
||||
inject_summary: Option<bool>,
|
||||
#[serde(default)]
|
||||
extract_model: Option<ModelManifest>,
|
||||
#[serde(default)]
|
||||
extract_threshold: Option<u64>,
|
||||
#[serde(default)]
|
||||
extract_worker_max_turns: Option<u32>,
|
||||
#[serde(default)]
|
||||
consolidation_model: Option<ModelManifest>,
|
||||
#[serde(default)]
|
||||
consolidation_threshold_files: Option<usize>,
|
||||
#[serde(default)]
|
||||
consolidation_threshold_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<ProfileMemoryConfig> for MemoryConfig {
|
||||
fn from(profile: ProfileMemoryConfig) -> Self {
|
||||
Self {
|
||||
workspace_root: profile.workspace_root,
|
||||
query_result_limit: profile.query_result_limit,
|
||||
query_excerpt_lines: profile.query_excerpt_lines,
|
||||
inject_summary: profile.inject_summary,
|
||||
workspace_id: None,
|
||||
settings_revision: None,
|
||||
language: None,
|
||||
extract_model: profile.extract_model,
|
||||
extract_threshold: profile.extract_threshold,
|
||||
extract_worker_max_turns: profile.extract_worker_max_turns,
|
||||
consolidation_model: profile.consolidation_model,
|
||||
consolidation_threshold_files: profile.consolidation_threshold_files,
|
||||
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProfileConfig {
|
||||
@@ -730,16 +677,12 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
feature: FeatureConfigPartial,
|
||||
#[serde(default)]
|
||||
plugins: PluginConfig,
|
||||
#[serde(default)]
|
||||
mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
#[serde(default)]
|
||||
memory: Option<ProfileMemoryConfig>,
|
||||
#[serde(default)]
|
||||
skills: Option<SkillsConfig>,
|
||||
}
|
||||
|
||||
@@ -940,12 +883,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
|
||||
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
||||
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
|
||||
}
|
||||
if let Some(memory) = &profile.memory
|
||||
&& let Some(root) = &memory.workspace_root
|
||||
&& root.is_absolute()
|
||||
{
|
||||
return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into()));
|
||||
}
|
||||
if let Some(skills) = &profile.skills {
|
||||
for dir in &skills.directories {
|
||||
if dir.is_absolute() {
|
||||
@@ -1024,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 {
|
||||
@@ -1031,6 +969,7 @@ fn profile_scope_intent_to_config(
|
||||
target: workspace_base.to_path_buf(),
|
||||
permission,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny,
|
||||
})
|
||||
@@ -1299,7 +1238,9 @@ mod tests {
|
||||
("settings_revision", serde_json::json!(2)),
|
||||
("language", serde_json::json!("Japanese")),
|
||||
] {
|
||||
let artifact = serde_json::json!({ "memory": { (field): value } });
|
||||
let artifact = serde_json::json!({
|
||||
"feature": { "memory": { (field): value } }
|
||||
});
|
||||
let error = resolve_profile_artifact_value(
|
||||
artifact,
|
||||
ProfileSource::Registry {
|
||||
@@ -1319,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();
|
||||
@@ -1351,14 +1337,12 @@ mod tests {
|
||||
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
|
||||
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
|
||||
}));
|
||||
assert!(!resolved.manifest.feature.memory.enabled);
|
||||
assert!(!resolved.manifest.feature.memory.profile.enabled);
|
||||
assert!(!resolved.manifest.feature.ticket.enabled);
|
||||
assert!(!resolved.manifest.feature.objective.enabled);
|
||||
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]
|
||||
@@ -1630,7 +1614,7 @@ enabled = false
|
||||
.unwrap();
|
||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||
assert!(resolved.manifest.feature.task.enabled);
|
||||
assert!(!resolved.manifest.feature.memory.enabled);
|
||||
assert!(!resolved.manifest.feature.memory.profile.enabled);
|
||||
assert!(resolved.manifest.feature.web.enabled);
|
||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||
assert!(resolved.manifest.feature.ticket.enabled);
|
||||
|
||||
+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();
|
||||
|
||||
@@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemoryConsolidateStagingOperation {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub threshold_files: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub threshold_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -450,10 +447,21 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::extract::{CandidateKind, ExtractedCandidate};
|
||||
|
||||
#[test]
|
||||
fn consolidation_operation_rejects_caller_owned_thresholds() {
|
||||
let error =
|
||||
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
|
||||
"force": false,
|
||||
"threshold_files": 1,
|
||||
}))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("threshold_files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_list_read_close_records_reason_and_deletes_candidate() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path());
|
||||
let layout = WorkspaceLayout::resolve(temp.path());
|
||||
let source = SourceRef {
|
||||
segment_id: "segment-1".into(),
|
||||
range: [0, 1],
|
||||
|
||||
@@ -21,8 +21,7 @@ pub struct StagingEntry {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
pub record: StagingRecord,
|
||||
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`)
|
||||
/// に使う。
|
||||
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ fn deny_write(target: &Path) -> ScopeRule {
|
||||
target: target.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,24 +70,12 @@ impl WorkspaceLayout {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Resolve a layout from a `MemoryConfig`.
|
||||
/// Resolve a layout from the nearest Memory marker.
|
||||
///
|
||||
/// An explicit `memory.workspace_root` is honored exactly. Without an
|
||||
/// explicit root, resolution searches `default_root` and its ancestors for
|
||||
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
|
||||
/// contain `.yoi` project records such as tickets from
|
||||
/// becoming independent memory roots merely because they contain `.yoi`.
|
||||
///
|
||||
/// If no memory marker exists, this falls back to `default_root` because
|
||||
/// existing call sites require a concrete layout. That fallback is a
|
||||
/// no-marker compatibility path, not a `.yoi` marker interpretation; it
|
||||
/// must not be used as evidence that `.yoi` alone enables repo-local
|
||||
/// memory.
|
||||
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
|
||||
if let Some(root) = &cfg.workspace_root {
|
||||
return Self::new(root.clone());
|
||||
}
|
||||
|
||||
/// Resolution searches `default_root` and its ancestors for the nearest
|
||||
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
|
||||
/// policy directly; resolved Worker Manifests do not carry storage paths.
|
||||
pub fn resolve(default_root: &Path) -> Self {
|
||||
let root =
|
||||
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
|
||||
Self::new(root)
|
||||
@@ -335,16 +323,6 @@ mod tests {
|
||||
assert!(matches!(err, LintError::InvalidPath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_workspace_root_when_set() {
|
||||
let cfg = manifest::MemoryConfig {
|
||||
workspace_root: Some(PathBuf::from("/explicit")),
|
||||
..Default::default()
|
||||
};
|
||||
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
|
||||
assert_eq!(layout.root(), Path::new("/explicit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -353,8 +331,7 @@ mod tests {
|
||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
|
||||
let cfg = manifest::MemoryConfig::default();
|
||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||
let layout = WorkspaceLayout::resolve(&child);
|
||||
assert_eq!(layout.root(), workspace.as_path());
|
||||
}
|
||||
|
||||
@@ -366,8 +343,7 @@ mod tests {
|
||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
|
||||
|
||||
let cfg = manifest::MemoryConfig::default();
|
||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||
let layout = WorkspaceLayout::resolve(&child);
|
||||
assert_eq!(layout.root(), workspace.as_path());
|
||||
}
|
||||
|
||||
@@ -381,8 +357,7 @@ mod tests {
|
||||
|
||||
assert_eq!(find_memory_marker_root(&child), None);
|
||||
|
||||
let cfg = manifest::MemoryConfig::default();
|
||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||
let layout = WorkspaceLayout::resolve(&child);
|
||||
assert_eq!(layout.root(), child.as_path());
|
||||
}
|
||||
}
|
||||
|
||||
+572
-104
@@ -11,6 +11,11 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use identity::{WorkerId, WorkerIdParseError};
|
||||
|
||||
/// Allocate an opaque idempotency key for one client Submit request.
|
||||
pub fn new_submission_request_id() -> String {
|
||||
uuid::Uuid::now_v7().to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -27,21 +32,264 @@ fn is_false(value: &bool) -> bool {
|
||||
// Method (Client → Worker via Unix Socket)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trusted Server → Runtime transport header carrying the authenticated
|
||||
/// browser Account identity for one Worker protocol connection.
|
||||
///
|
||||
/// Runtime accepts this only after its normal HTTP authentication succeeds;
|
||||
/// serialized [`Method`] payloads cannot set authenticated source identity.
|
||||
pub const AUTHENTICATED_ACCOUNT_ID_HEADER: &str = "x-yoi-authenticated-account-id";
|
||||
|
||||
/// Trusted source identity attached by an authenticated transport boundary.
|
||||
///
|
||||
/// Public clients cannot select this value directly. Runtime/Backend adapters
|
||||
/// stamp it before forwarding an accepted Submit or Notify to a Worker.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum AuthenticatedInputSource {
|
||||
/// Assigned whenever a serialized tracked method crosses an untrusted
|
||||
/// protocol boundary. Receivers must handle it exactly like public input.
|
||||
UntrustedWire,
|
||||
Account {
|
||||
account_id: String,
|
||||
},
|
||||
Worker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
SubWorker {
|
||||
session_id: String,
|
||||
},
|
||||
Backend {
|
||||
operation_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for AuthenticatedInputSource {
|
||||
fn default() -> Self {
|
||||
Self::UntrustedWire
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthenticatedInputSource {
|
||||
pub fn namespace(&self) -> String {
|
||||
match self {
|
||||
Self::UntrustedWire => "untrusted-wire".into(),
|
||||
Self::Account { account_id } => format!("account:{account_id}"),
|
||||
Self::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => format!("worker:{runtime_id}:{worker_id}"),
|
||||
Self::SubWorker { session_id } => format!("sub_worker:{session_id}"),
|
||||
Self::Backend { operation_id } => format!("backend:{operation_id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable identity and revision fence for one state-changing Worker command.
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerCommandKind {
|
||||
Resume,
|
||||
Cancel,
|
||||
Pause,
|
||||
Compact,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerCommandDisposition {
|
||||
Accepted,
|
||||
StaleExecutionGeneration,
|
||||
StaleWorkerStateRevision,
|
||||
StaleCommandId,
|
||||
Conflict,
|
||||
InvalidState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkerCommandAcknowledgement {
|
||||
pub command_id: u64,
|
||||
pub command: WorkerCommandKind,
|
||||
pub disposition: WorkerCommandDisposition,
|
||||
/// The complete authoritative state observed after command admission.
|
||||
pub state: WorkerStateSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", content = "state", rename_all = "snake_case")]
|
||||
pub enum WorkerState {
|
||||
Idle,
|
||||
Busy(WorkerBusyState),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", content = "state", rename_all = "snake_case")]
|
||||
pub enum WorkerBusyState {
|
||||
Run(WorkerRunState),
|
||||
Maintenance(WorkerMaintenanceState),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerRunState {
|
||||
Running,
|
||||
Pausing,
|
||||
Paused,
|
||||
Cancelling,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerMaintenanceState {
|
||||
Compacting,
|
||||
}
|
||||
|
||||
#[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.
|
||||
pub last_command_id: u64,
|
||||
pub state: WorkerState,
|
||||
}
|
||||
|
||||
impl WorkerStateSnapshot {
|
||||
pub fn initial(execution_generation: u64) -> Self {
|
||||
Self {
|
||||
execution_generation,
|
||||
revision: 0,
|
||||
last_command_id: 0,
|
||||
state: WorkerState::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility projection for Runtime catalog lifecycle. This value is
|
||||
/// never command-admission authority and cannot produce `Stopped`.
|
||||
pub fn catalog_status(&self) -> WorkerStatus {
|
||||
match self.state {
|
||||
WorkerState::Idle => WorkerStatus::Idle,
|
||||
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)) => WorkerStatus::Paused,
|
||||
WorkerState::Busy(WorkerBusyState::Run(_))
|
||||
| WorkerState::Busy(WorkerBusyState::Maintenance(_)) => WorkerStatus::Running,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
WorkerStatus::Idle | WorkerStatus::Stopped => WorkerState::Idle,
|
||||
WorkerStatus::Running => {
|
||||
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running))
|
||||
}
|
||||
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
|
||||
};
|
||||
Self {
|
||||
execution_generation: 1,
|
||||
revision: 0,
|
||||
last_command_id: 0,
|
||||
state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
||||
pub enum Method {
|
||||
Run {
|
||||
/// Durably accept typed input for immediate activation or the session FIFO.
|
||||
///
|
||||
/// `submission_request_id` is generated by the authenticated caller and is
|
||||
/// used only for idempotent retry. Worker allocates the durable
|
||||
/// `submission_id` returned by [`Event::SubmissionAccepted`].
|
||||
Submit {
|
||||
submission_request_id: String,
|
||||
input: Vec<Segment>,
|
||||
},
|
||||
/// Runtime-internal Run carrying an opaque correlation id that is committed
|
||||
/// with the resulting UserInput entry. This variant is not serializable on
|
||||
/// the public Client → Worker protocol.
|
||||
#[serde(skip)]
|
||||
/// Authenticated transport form of Submit. Trusted adapters replace
|
||||
/// public Submit before forwarding it to the Worker.
|
||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||
RunTracked {
|
||||
SubmitTracked {
|
||||
submission_request_id: String,
|
||||
input: Vec<Segment>,
|
||||
submission_id: String,
|
||||
#[serde(skip_deserializing, default)]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
/// Human-readable text injected into the target Worker's LLM context
|
||||
/// as a non-blocking system message. `auto_run` controls whether an
|
||||
@@ -50,26 +298,63 @@ pub enum Method {
|
||||
/// No side effects beyond LLM context; use `WorkerEvent` for typed
|
||||
/// lifecycle reports.
|
||||
Notify {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||
auto_run: bool,
|
||||
},
|
||||
/// Authenticated transport form of Notify.
|
||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||
NotifyTracked {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||
auto_run: bool,
|
||||
#[serde(skip_deserializing, default)]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
/// Typed lifecycle report from a child Worker to its direct parent.
|
||||
WorkerEvent(WorkerEvent),
|
||||
Resume,
|
||||
Cancel,
|
||||
/// Return the authoritative FIFO summary without exposing queued payloads.
|
||||
ListPendingSubmissions,
|
||||
/// Remove one queued submission. Running or already activated submissions
|
||||
/// are immutable and therefore cannot be cancelled here.
|
||||
CancelPendingSubmission {
|
||||
submission_id: String,
|
||||
expected_revision: u64,
|
||||
},
|
||||
/// Remove every queued submission while preserving the active run.
|
||||
ClearPendingSubmissions {
|
||||
expected_revision: u64,
|
||||
},
|
||||
/// Activate the next queued submission while the Worker is idle. This is an
|
||||
/// explicit recovery operation and never resumes a paused run implicitly.
|
||||
ContinuePending {
|
||||
expected_revision: u64,
|
||||
expected_head_id: String,
|
||||
},
|
||||
Resume {
|
||||
command: WorkerCommandEnvelope,
|
||||
},
|
||||
Cancel {
|
||||
command: WorkerCommandEnvelope,
|
||||
},
|
||||
/// Stop the in-flight turn and transition to `Paused`.
|
||||
///
|
||||
/// Unlike `Cancel` (which discards and returns to `Idle`), a paused
|
||||
/// Worker can resume the interrupted work via `Resume`, or start a
|
||||
/// fresh turn via `Run` (orphan `tool_use` items are closed with a
|
||||
/// Worker can resume the interrupted work via `Resume`, or accept a
|
||||
/// fresh `Submit` (orphan `tool_use` items are closed with a
|
||||
/// synthetic tool result before the new user message is appended).
|
||||
Pause,
|
||||
Pause {
|
||||
command: WorkerCommandEnvelope,
|
||||
},
|
||||
/// Request an explicit compaction while the Worker is otherwise idle.
|
||||
///
|
||||
/// This is a typed control method: clients must not send `compact` as a
|
||||
/// `Method::Run` user message.
|
||||
Compact,
|
||||
/// `Method::Submit` user message.
|
||||
Compact {
|
||||
command: WorkerCommandEnvelope,
|
||||
},
|
||||
/// Ask the Worker to list valid rewind targets from its authoritative session log.
|
||||
ListRewindTargets,
|
||||
/// Truncate the current session back to the selected rewind target and
|
||||
@@ -78,7 +363,9 @@ pub enum Method {
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
},
|
||||
Shutdown,
|
||||
Shutdown {
|
||||
command: WorkerCommandEnvelope,
|
||||
},
|
||||
/// Request a list of completion candidates from the Worker.
|
||||
///
|
||||
/// Reply is sent on the same socket as `Event::Completions` (not
|
||||
@@ -181,7 +468,7 @@ impl WorkerEvent {
|
||||
|
||||
/// One typed piece of a user submission.
|
||||
///
|
||||
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
||||
/// `Method::Submit` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
||||
/// clients (CLI piping, scripts) only need to produce a single
|
||||
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
||||
/// (paste chips, file refs) and
|
||||
@@ -404,12 +691,13 @@ impl Segment {
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// Convenience: a `Run` carrying a single `Segment::Text`.
|
||||
/// Convenience: a `Submit` carrying a single `Segment::Text`.
|
||||
/// Used by dumb clients, inter-Worker tools, and tests that only have
|
||||
/// a string to forward.
|
||||
pub fn run_text(s: impl Into<String>) -> Self {
|
||||
Self::Run {
|
||||
input: vec![Segment::text(s)],
|
||||
pub fn submit_text(submission_request_id: impl Into<String>, text: impl Into<String>) -> Self {
|
||||
Self::Submit {
|
||||
submission_request_id: submission_request_id.into(),
|
||||
input: vec![Segment::text(text)],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,6 +791,39 @@ pub enum ToolResultDisposition {
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
/// Durable acceptance result for one idempotent Submit request.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubmissionDisposition {
|
||||
Started,
|
||||
Queued,
|
||||
}
|
||||
|
||||
/// Bounded public projection of one pending submission. Payload segments and
|
||||
/// provenance remain in the session log and are intentionally not exposed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct PendingSubmissionSummary {
|
||||
pub submission_id: String,
|
||||
pub accepted_at_ms: u64,
|
||||
pub segment_count: u32,
|
||||
pub byte_len: u64,
|
||||
}
|
||||
|
||||
/// Revisioned session-owned FIFO projection used by snapshots and live events.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct PendingSubmissionsSnapshot {
|
||||
pub revision: u64,
|
||||
#[serde(default)]
|
||||
pub notification_count: u32,
|
||||
#[serde(default)]
|
||||
pub head_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub submissions: Vec<PendingSubmissionSummary>,
|
||||
}
|
||||
|
||||
/// Canonical, storage-independent projection of committed session history.
|
||||
///
|
||||
/// Worker protocols expose this DTO instead of append-log records. New
|
||||
@@ -511,6 +832,8 @@ pub enum ToolResultDisposition {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SessionSnapshot {
|
||||
#[serde(default)]
|
||||
pub pending_submissions: PendingSubmissionsSnapshot,
|
||||
pub entries: Vec<SessionSnapshotEntry>,
|
||||
}
|
||||
|
||||
@@ -609,16 +932,28 @@ pub struct SessionToolAttachment {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
/// A user input message was accepted, persisted as
|
||||
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn.
|
||||
/// Broadcast to every subscribed client so TUI / GUI instances show
|
||||
/// the same user line that reconnect snapshots would replay from
|
||||
/// history; clients must not synthesize a separate pending/fake
|
||||
/// message for accepted runs.
|
||||
///
|
||||
/// Fires exactly once per committed user input, after
|
||||
/// `InvokeStart { kind: UserSend }` and before the first
|
||||
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit.
|
||||
/// Durable Submit acceptance. A `Queued` receipt follows the durable FIFO
|
||||
/// checkpoint. A `Started` receipt follows the pending activation checkpoint;
|
||||
/// the corresponding [`Event::UserMessage`] marks the later history commit.
|
||||
/// Repeating the same request id and exact payload returns the same receipt
|
||||
/// without appending or activating twice.
|
||||
SubmissionAccepted {
|
||||
submission_request_id: String,
|
||||
submission_id: String,
|
||||
disposition: SubmissionDisposition,
|
||||
},
|
||||
/// Correlated rejection before durable acceptance.
|
||||
SubmissionRejected {
|
||||
submission_request_id: String,
|
||||
message: String,
|
||||
},
|
||||
/// Revisioned FIFO replacement following enqueue, activation, cancel, or clear.
|
||||
PendingSubmissionsChanged {
|
||||
pending: PendingSubmissionsSnapshot,
|
||||
},
|
||||
/// A user input message persisted as `LogEntry::AnnotatedUserInput` and
|
||||
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI
|
||||
/// instances show the same user line that reconnect snapshots replay.
|
||||
UserMessage {
|
||||
segments: Vec<Segment>,
|
||||
},
|
||||
@@ -641,7 +976,7 @@ pub enum Event {
|
||||
///
|
||||
/// Marker event for the start of an Invoke range; the range extends
|
||||
/// implicitly until the next `InvokeStart`. Fires for every accepted
|
||||
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
||||
/// `Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
|
||||
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
|
||||
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
|
||||
/// typed system reminder insertion that doesn't break IDLE) do not
|
||||
@@ -798,8 +1133,9 @@ pub enum Event {
|
||||
Snapshot {
|
||||
session: SessionSnapshot,
|
||||
greeting: Greeting,
|
||||
#[serde(default)]
|
||||
status: WorkerStatus,
|
||||
/// Full revisioned live execution state. `Stopped` remains Runtime
|
||||
/// catalog authority and is deliberately not represented here.
|
||||
state: WorkerStateSnapshot,
|
||||
/// Unfinished model output that has already streamed in the current
|
||||
/// run but is not yet represented by committed snapshot entries.
|
||||
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
|
||||
@@ -836,8 +1172,11 @@ pub enum Event {
|
||||
},
|
||||
/// Current Worker controller status. Broadcast on every controller-level
|
||||
/// transition and included in `History` snapshots for late attach.
|
||||
Status {
|
||||
status: WorkerStatus,
|
||||
WorkerState {
|
||||
snapshot: WorkerStateSnapshot,
|
||||
},
|
||||
CommandAcknowledged {
|
||||
acknowledgement: WorkerCommandAcknowledgement,
|
||||
},
|
||||
/// Bounded, provider-owned command telemetry for the live Console. This is
|
||||
/// intentionally not a history entry and is reconstructed from
|
||||
@@ -1193,7 +1532,7 @@ pub enum TurnResult {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvokeKind {
|
||||
/// `Method::Run` — a user submission.
|
||||
/// `Method::Submit` — a user submission.
|
||||
UserSend,
|
||||
/// `Method::Notify` — free-text notification injected into history.
|
||||
Notify,
|
||||
@@ -1216,7 +1555,7 @@ pub enum RunResult {
|
||||
Finished,
|
||||
Paused,
|
||||
LimitReached,
|
||||
/// The accepted Method::Run produced no assistant/tool output before
|
||||
/// The accepted Method::Submit produced no assistant/tool output before
|
||||
/// user interruption, so the Worker rolled the submit-time turn state back
|
||||
/// to its pre-submit snapshot. Clients should treat the Worker as Idle and
|
||||
/// restore the just-submitted input into the editable composer if desired.
|
||||
@@ -1260,12 +1599,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
|
||||
@@ -1285,26 +1642,101 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn method_run_json_roundtrip() {
|
||||
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"Hello"}]}}"#;
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[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));
|
||||
|
||||
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 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()
|
||||
};
|
||||
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,
|
||||
})
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_submit_json_roundtrip_and_run_is_rejected() {
|
||||
let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"Hello"}]}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
match &method {
|
||||
Method::Run { input } => {
|
||||
Method::Submit { input, .. } => {
|
||||
assert_eq!(input.len(), 1);
|
||||
match &input[0] {
|
||||
Segment::Text { content } => assert_eq!(content, "Hello"),
|
||||
other => panic!("expected Text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
other => panic!("expected Submit, got {other:?}"),
|
||||
}
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(serialized, json);
|
||||
assert!(
|
||||
serde_json::from_str::<Method>(r#"{"method":"run","params":{"input":[]}}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_paste_segment_roundtrip() {
|
||||
let method = Method::Run {
|
||||
fn method_submit_paste_segment_roundtrip() {
|
||||
let method = Method::Submit {
|
||||
submission_request_id: "request-1".to_string(),
|
||||
input: vec![
|
||||
Segment::text("see "),
|
||||
Segment::Paste {
|
||||
@@ -1318,7 +1750,7 @@ mod tests {
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Method::Run { input } => {
|
||||
Method::Submit { input, .. } => {
|
||||
assert_eq!(input.len(), 2);
|
||||
match &input[1] {
|
||||
Segment::Paste {
|
||||
@@ -1335,7 +1767,7 @@ mod tests {
|
||||
other => panic!("expected Paste, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
other => panic!("expected Submit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,8 +1821,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_flow_segment_roundtrip() {
|
||||
let method = Method::Run {
|
||||
fn method_submit_flow_segment_roundtrip() {
|
||||
let method = Method::Submit {
|
||||
submission_request_id: "request-1".to_string(),
|
||||
input: vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
@@ -1404,7 +1837,7 @@ mod tests {
|
||||
let decoded = serde_json::from_str::<Method>(&json).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Method::Run { input }
|
||||
Method::Submit { input, .. }
|
||||
if matches!(
|
||||
input.as_slice(),
|
||||
[
|
||||
@@ -1416,15 +1849,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_tracked_run_is_not_public_protocol_json() {
|
||||
let method = Method::RunTracked {
|
||||
fn authenticated_submit_replaces_wire_source_with_transport_identity() {
|
||||
let method = Method::SubmitTracked {
|
||||
input: vec![Segment::text("private")],
|
||||
submission_id: "submission-1".to_string(),
|
||||
submission_request_id: "request-1".to_string(),
|
||||
source: AuthenticatedInputSource::Account {
|
||||
account_id: "account-1".into(),
|
||||
},
|
||||
};
|
||||
assert!(serde_json::to_string(&method).is_err());
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
let decoded = serde_json::from_str::<Method>(&json).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Method::SubmitTracked {
|
||||
source: AuthenticatedInputSource::UntrustedWire,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_str::<Method>(
|
||||
r#"{"method":"run_tracked","input":[],"submission_id":"forged"}"#,
|
||||
r#"{"method":"submit_tracked","input":[],"submission_request_id":"forged"}"#,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
@@ -1442,42 +1886,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_run_with_unknown_segment_decodes() {
|
||||
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#;
|
||||
fn method_submit_with_unknown_segment_decodes() {
|
||||
let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
match method {
|
||||
Method::Run { input } => {
|
||||
Method::Submit { input, .. } => {
|
||||
assert_eq!(input.len(), 2);
|
||||
assert!(matches!(input[0], Segment::Text { .. }));
|
||||
assert!(matches!(input[1], Segment::Unknown));
|
||||
}
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
other => panic!("expected Submit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_without_params() {
|
||||
let json = r#"{"method":"resume"}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(method, Method::Resume));
|
||||
fn lifecycle_method_without_command_fails_closed() {
|
||||
let error = serde_json::from_str::<Method>(r#"{"method":"resume"}"#).unwrap_err();
|
||||
assert!(error.to_string().contains("params"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_pause_roundtrip() {
|
||||
let json = r#"{"method":"pause"}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(method, Method::Pause));
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(serialized, json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_compact_roundtrip() {
|
||||
let json = r#"{"method":"compact"}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(method, Method::Compact));
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(serialized, json);
|
||||
fn lifecycle_methods_roundtrip_with_fences() {
|
||||
for method in [
|
||||
Method::Pause {
|
||||
command: WorkerCommandEnvelope {
|
||||
command_id: 11,
|
||||
expected_execution_generation: 4,
|
||||
expected_worker_state_revision: 8,
|
||||
},
|
||||
},
|
||||
Method::Compact {
|
||||
command: WorkerCommandEnvelope {
|
||||
command_id: 12,
|
||||
expected_execution_generation: 4,
|
||||
expected_worker_state_revision: 9,
|
||||
},
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1648,11 +2103,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn method_notify_json_roundtrip_defaults_to_auto_run() {
|
||||
let json = r#"{"method":"notify","params":{"message":"turn done"}}"#;
|
||||
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"turn done"}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
method,
|
||||
Method::Notify { ref message, auto_run: true } if message == "turn done"
|
||||
Method::Notify { ref message, auto_run: true, .. } if message == "turn done"
|
||||
));
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(serialized, json);
|
||||
@@ -1660,11 +2115,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn method_notify_weak_json_roundtrip_serializes_auto_run_false() {
|
||||
let json = r#"{"method":"notify","params":{"message":"progress","auto_run":false}}"#;
|
||||
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"progress","auto_run":false}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
method,
|
||||
Method::Notify { ref message, auto_run: false } if message == "progress"
|
||||
Method::Notify { ref message, auto_run: false, .. } if message == "progress"
|
||||
));
|
||||
assert_eq!(serde_json::to_string(&method).unwrap(), json);
|
||||
}
|
||||
@@ -1725,6 +2180,7 @@ mod tests {
|
||||
fn event_snapshot_format() {
|
||||
let event = Event::Snapshot {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![SessionSnapshotEntry {
|
||||
entry_id: "entry-1".into(),
|
||||
timestamp: 1,
|
||||
@@ -1745,7 +2201,7 @@ mod tests {
|
||||
context_window: 200_000,
|
||||
context_tokens: 42_000,
|
||||
},
|
||||
status: WorkerStatus::Paused,
|
||||
state: WorkerStatus::Paused.into(),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: Vec::new(),
|
||||
};
|
||||
@@ -1762,12 +2218,13 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
|
||||
assert_eq!(parsed["data"]["status"], "paused");
|
||||
assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy");
|
||||
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
|
||||
}
|
||||
|
||||
#[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":[]},"status":"running"}}"#;
|
||||
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 decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||
match decoded {
|
||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||
@@ -1776,6 +2233,7 @@ mod tests {
|
||||
|
||||
let event = Event::Snapshot {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: Greeting {
|
||||
@@ -1788,7 +2246,7 @@ mod tests {
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: WorkerStatus::Running,
|
||||
state: WorkerStatus::Running.into(),
|
||||
in_flight: InFlightSnapshot {
|
||||
blocks: vec![
|
||||
InFlightBlock::Text {
|
||||
@@ -1844,6 +2302,7 @@ mod tests {
|
||||
fn event_segment_rotated_roundtrip() {
|
||||
let event = Event::SegmentRotated {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
};
|
||||
@@ -1875,20 +2334,32 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_status_format() {
|
||||
let event = Event::Status {
|
||||
status: WorkerStatus::Running,
|
||||
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)),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "status");
|
||||
assert_eq!(parsed["data"]["status"], "running");
|
||||
assert_eq!(parsed["event"], "worker_state");
|
||||
assert_eq!(parsed["data"]["snapshot"]["execution_generation"], 7);
|
||||
assert_eq!(parsed["data"]["snapshot"]["revision"], 3);
|
||||
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
|
||||
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Event::Status {
|
||||
status: WorkerStatus::Running
|
||||
Event::WorkerState {
|
||||
snapshot: WorkerStateSnapshot {
|
||||
execution_generation: 7,
|
||||
revision: 3,
|
||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||
..
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -1929,19 +2400,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_snapshot_without_status_defaults_to_idle() {
|
||||
fn event_snapshot_without_worker_state_fails_closed() {
|
||||
let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
||||
let decoded: Event = serde_json::from_str(json).unwrap();
|
||||
match decoded {
|
||||
Event::Snapshot {
|
||||
status, greeting, ..
|
||||
} => {
|
||||
assert_eq!(status, WorkerStatus::Idle);
|
||||
assert_eq!(greeting.context_window, 0);
|
||||
assert_eq!(greeting.context_tokens, 0);
|
||||
}
|
||||
other => panic!("expected Snapshot, got {other:?}"),
|
||||
}
|
||||
let error = serde_json::from_str::<Event>(json).unwrap_err();
|
||||
assert!(error.to_string().contains("state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2037,6 +2499,7 @@ mod tests {
|
||||
target: "/tmp/work".into(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
});
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
@@ -2354,7 +2817,12 @@ mod tests {
|
||||
"scope_summary": "scope",
|
||||
"tools": []
|
||||
},
|
||||
"status": "idle"
|
||||
"state": {
|
||||
"execution_generation": 1,
|
||||
"revision": 0,
|
||||
"last_command_id": 0,
|
||||
"state": { "kind": "idle" }
|
||||
}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
@@ -573,6 +573,11 @@ pub struct SubscriptionWorker {
|
||||
pub resource_key: Option<String>,
|
||||
/// Producer-owned monotonic revision for this Worker subject.
|
||||
pub subject_revision: u64,
|
||||
/// Latest revisioned foreground state observed from the Worker. This remains
|
||||
/// absent until an authoritative Worker snapshot/event has been applied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_state: Option<crate::WorkerStateSnapshot>,
|
||||
/// Runtime catalog lifecycle compatibility projection; not foreground-state authority.
|
||||
pub state: SubscriptionWorkerState,
|
||||
#[serde(default)]
|
||||
pub has_running_internal_workers: bool,
|
||||
@@ -874,6 +879,7 @@ mod tests {
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
subject_revision: 0,
|
||||
worker_state: None,
|
||||
state: SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
workspace_id: Some("workspace-1".to_string()),
|
||||
|
||||
@@ -8,11 +8,14 @@ use crate::{
|
||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent,
|
||||
WorkerStatus,
|
||||
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,
|
||||
@@ -46,12 +49,22 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<AlertSource>(&cfg, &mut output);
|
||||
push_decl::<CompletionKind>(&cfg, &mut output);
|
||||
push_decl::<WorkerStatus>(&cfg, &mut output);
|
||||
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
|
||||
push_decl::<WorkerCommandKind>(&cfg, &mut output);
|
||||
push_decl::<WorkerCommandDisposition>(&cfg, &mut output);
|
||||
push_decl::<WorkerCommandAcknowledgement>(&cfg, &mut output);
|
||||
push_decl::<WorkerRunState>(&cfg, &mut output);
|
||||
push_decl::<WorkerMaintenanceState>(&cfg, &mut output);
|
||||
push_decl::<WorkerBusyState>(&cfg, &mut output);
|
||||
push_decl::<WorkerState>(&cfg, &mut output);
|
||||
push_decl::<WorkerStateSnapshot>(&cfg, &mut output);
|
||||
push_decl::<TurnResult>(&cfg, &mut output);
|
||||
push_decl::<InvokeKind>(&cfg, &mut output);
|
||||
push_decl::<RunResult>(&cfg, &mut output);
|
||||
push_decl::<ToolResultDisposition>(&cfg, &mut output);
|
||||
push_decl::<ErrorCode>(&cfg, &mut output);
|
||||
push_decl::<Permission>(&cfg, &mut output);
|
||||
push_decl::<SymlinkPolicy>(&cfg, &mut output);
|
||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||
push_decl::<CommandStatus>(&cfg, &mut output);
|
||||
push_decl::<CommandStream>(&cfg, &mut output);
|
||||
@@ -75,6 +88,9 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
||||
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
|
||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshot>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||
|
||||
@@ -21,8 +21,10 @@ use crate::segment_log::LogEntry;
|
||||
use crate::store::{Store, StoreError};
|
||||
use crate::uploaded_file::{
|
||||
bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files,
|
||||
delete_uncommitted_uploaded_files, delete_uploaded_file, list_uploaded_file_refs,
|
||||
read_uploaded_file, read_uploaded_file_by_id, write_uploaded_file,
|
||||
delete_uncommitted_uploaded_files, delete_uploaded_file, finalize_uploaded_file_binding,
|
||||
list_uploaded_file_refs, pin_uploaded_file, read_uploaded_file, read_uploaded_file_by_id,
|
||||
reconcile_uploaded_file_pins, release_uploaded_file_pin, uploaded_file_has_pending_owner,
|
||||
write_uploaded_file,
|
||||
};
|
||||
use crate::{
|
||||
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
|
||||
@@ -518,6 +520,61 @@ impl Store for FsStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn pin_uploaded_file(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
reference: &UploadedFileRef,
|
||||
owner_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
pin_uploaded_file(&self.paste_artifact_dir(session_id), reference, owner_id)
|
||||
}
|
||||
|
||||
fn release_uploaded_file_pin(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
owner_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
release_uploaded_file_pin(&self.paste_artifact_dir(session_id), artifact_id, owner_id)
|
||||
}
|
||||
|
||||
fn finalize_uploaded_file_binding(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
artifact_id: &str,
|
||||
source_entry_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
finalize_uploaded_file_binding(
|
||||
&self.paste_artifact_dir(session_id),
|
||||
artifact_id,
|
||||
source_entry_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn reconcile_uploaded_file_pins(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
live_owner_ids: &[String],
|
||||
) -> Result<u64, StoreError> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
reconcile_uploaded_file_pins(&self.paste_artifact_dir(session_id), live_owner_ids)
|
||||
}
|
||||
|
||||
fn delete_uploaded_file(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
@@ -541,13 +598,18 @@ impl Store for FsStore {
|
||||
let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if !self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
|
||||
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
|
||||
if delete_uploaded_file(&dir, &reference.artifact_id)? {
|
||||
removed = removed
|
||||
.checked_add(1)
|
||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
||||
}
|
||||
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
|
||||
finalize_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
|
||||
continue;
|
||||
}
|
||||
if uploaded_file_has_pending_owner(&dir, &reference.artifact_id)? {
|
||||
continue;
|
||||
}
|
||||
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
|
||||
if delete_uploaded_file(&dir, &reference.artifact_id)? {
|
||||
removed = removed
|
||||
.checked_add(1)
|
||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
@@ -865,6 +927,106 @@ mod tests {
|
||||
assert!(store.read_uploaded_file(owner, &reference).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_upload_pin_survives_cleanup_until_release_or_history_binding() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let limits = UploadedFileLimits {
|
||||
max_file_bytes: 64,
|
||||
max_session_bytes: 128,
|
||||
};
|
||||
let pending = store
|
||||
.write_uploaded_file(session_id, "pending.txt", "text/plain", b"pending", limits)
|
||||
.unwrap();
|
||||
store
|
||||
.pin_uploaded_file(session_id, &pending, "submission-1")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.pin_uploaded_file(session_id, &pending, "submission-other"),
|
||||
Err(StoreError::ArtifactAlreadyCommitted)
|
||||
));
|
||||
drop(store);
|
||||
let store = FsStore::new(tmp.path()).unwrap();
|
||||
assert_eq!(
|
||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
|
||||
.unwrap()
|
||||
.1,
|
||||
b"pending"
|
||||
);
|
||||
|
||||
let fork_session_id = new_session_id();
|
||||
assert_eq!(
|
||||
store
|
||||
.copy_committed_uploaded_files(session_id, fork_session_id)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.read_uploaded_file_by_id(fork_session_id, &pending.artifact_id)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let committed = store
|
||||
.bind_uploaded_file(session_id, &pending, "entry-1")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
|
||||
.is_ok()
|
||||
);
|
||||
store
|
||||
.create_segment(
|
||||
session_id,
|
||||
new_segment_id(),
|
||||
&[LogEntry::InputSegmentsCheckpoint {
|
||||
ts: 1,
|
||||
user_segments: vec![vec![protocol::Segment::UploadedFile {
|
||||
file: committed.clone(),
|
||||
}]],
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.release_uploaded_file_pin(session_id, &pending.artifact_id, "submission-1")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let releasable = store
|
||||
.write_uploaded_file(session_id, "cancelled.txt", "text/plain", b"cancel", limits)
|
||||
.unwrap();
|
||||
store
|
||||
.pin_uploaded_file(session_id, &releasable, "submission-2")
|
||||
.unwrap();
|
||||
store
|
||||
.release_uploaded_file_pin(session_id, &releasable.artifact_id, "submission-2")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.read_uploaded_file_by_id(session_id, &releasable.artifact_id)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_file_validation_and_shared_quota_fail_closed() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -183,6 +183,7 @@ fn canonicalize_history_entry(
|
||||
item,
|
||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||
},
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
|
||||
entries.push(history_entry(entry, *ts, data));
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry(
|
||||
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
|
||||
&entry.item,
|
||||
entry.metadata.entry_id.0.clone(),
|
||||
*ts,
|
||||
@@ -100,7 +100,10 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
|
||||
}
|
||||
}
|
||||
|
||||
SessionSnapshot { entries }
|
||||
SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_history(
|
||||
|
||||
@@ -287,6 +287,7 @@ pub fn append_system_item(
|
||||
LogEntry::AnnotatedSystemItem {
|
||||
ts: segment_log::now_millis(),
|
||||
entry,
|
||||
extensions: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ pub enum LogEntry {
|
||||
AnnotatedSystemItem {
|
||||
ts: u64,
|
||||
entry: LoggedSystemHistoryEntry,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
extensions: Vec<SessionExtension>,
|
||||
},
|
||||
|
||||
/// Turn boundary. Records the turn count after increment.
|
||||
@@ -312,12 +314,19 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.annotated_history.push(entry.clone());
|
||||
state.history.push(Item::from(entry.item.clone()));
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
LogEntry::AnnotatedSystemItem {
|
||||
entry, extensions, ..
|
||||
} => {
|
||||
state.annotated_history.push(LoggedHistoryEntry {
|
||||
item: LoggedItem::from(entry.item.to_history_item()),
|
||||
metadata: entry.metadata.clone(),
|
||||
});
|
||||
state.history.push(entry.item.to_history_item());
|
||||
state.extensions.extend(
|
||||
extensions
|
||||
.iter()
|
||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
||||
);
|
||||
}
|
||||
LogEntry::TurnEnd { turn_count, .. } => {
|
||||
if let Some(active_turn_count) = &mut state.active_run_turn_count {
|
||||
|
||||
@@ -226,6 +226,47 @@ pub trait Store: Send + Sync {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Retain an uploaded file while a durable pending operation owns it.
|
||||
fn pin_uploaded_file(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_reference: &UploadedFileRef,
|
||||
_owner_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Release a pending-operation pin without changing committed ownership.
|
||||
fn release_uploaded_file_pin(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_artifact_id: &str,
|
||||
_owner_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Complete the pending-to-history handoff after the history entry commits.
|
||||
fn finalize_uploaded_file_binding(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_artifact_id: &str,
|
||||
_source_entry_id: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
Err(StoreError::PasteArtifactUnsupported)
|
||||
}
|
||||
|
||||
/// Clear pending-operation pins that have no owner in restored durable
|
||||
/// Worker Session state. This repairs an interrupted pin-before-checkpoint
|
||||
/// acceptance without disturbing live queue owners or committed history.
|
||||
fn reconcile_uploaded_file_pins(
|
||||
&self,
|
||||
_session_id: SessionId,
|
||||
_live_owner_ids: &[String],
|
||||
) -> Result<u64, StoreError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Delete an uncommitted uploaded file owned by `session_id`.
|
||||
fn delete_uploaded_file(
|
||||
&self,
|
||||
|
||||
@@ -24,6 +24,12 @@ pub const DEFAULT_MAX_FILES_PER_SUBMISSION: usize = 8;
|
||||
pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256;
|
||||
const MAX_FILE_NAME_CHARS: usize = 255;
|
||||
const MAX_MEDIA_TYPE_BYTES: usize = 127;
|
||||
fn validate_pending_owner_id(owner_id: &str) -> Result<()> {
|
||||
if owner_id.is_empty() || owner_id.len() > 256 {
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UploadedFileLimits {
|
||||
@@ -59,6 +65,8 @@ struct StoredUploadedFile {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
source_entry_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pending_owner_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
upload_context: Option<UploadedFileUploadContext>,
|
||||
content_base64: String,
|
||||
}
|
||||
@@ -291,6 +299,7 @@ pub(crate) fn write_uploaded_file(
|
||||
byte_len,
|
||||
sha256: sha256.clone(),
|
||||
source_entry_id: None,
|
||||
pending_owner_id: None,
|
||||
upload_context: context.cloned(),
|
||||
content_base64: BASE64.encode(content),
|
||||
};
|
||||
@@ -338,6 +347,12 @@ pub(crate) fn read_uploaded_file_by_id(
|
||||
Ok((reference, content))
|
||||
}
|
||||
|
||||
pub(crate) fn uploaded_file_has_pending_owner(dir: &Path, artifact_id: &str) -> Result<bool> {
|
||||
let path = record_path(dir, artifact_id)?;
|
||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(path)?)?;
|
||||
Ok(stored.pending_owner_id.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn read_uploaded_file(dir: &Path, reference: &UploadedFileRef) -> Result<Vec<u8>> {
|
||||
let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
|
||||
if stored_reference.file_name != reference.file_name
|
||||
@@ -376,6 +391,98 @@ pub(crate) fn clear_uploaded_file_binding(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn pin_uploaded_file(
|
||||
dir: &Path,
|
||||
reference: &UploadedFileRef,
|
||||
owner_id: &str,
|
||||
) -> Result<()> {
|
||||
validate_pending_owner_id(owner_id)?;
|
||||
if reference.source_entry_id.is_some() {
|
||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
||||
}
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let path = record_path(dir, &reference.artifact_id)?;
|
||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
if stored.file_name != reference.file_name
|
||||
|| stored.media_type != reference.media_type
|
||||
|| stored.created_at_ms != reference.created_at_ms
|
||||
|| stored.byte_len != reference.byte_len
|
||||
|| stored.sha256 != reference.sha256
|
||||
{
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
if stored.source_entry_id.is_some() {
|
||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
||||
}
|
||||
if let Some(existing_owner) = stored.pending_owner_id.as_deref() {
|
||||
return if existing_owner == owner_id {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StoreError::ArtifactAlreadyCommitted)
|
||||
};
|
||||
}
|
||||
stored.pending_owner_id = Some(owner_id.to_owned());
|
||||
let temp = dir.join(format!(".{}.file.pin.tmp", reference.artifact_id));
|
||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
||||
fs::rename(temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn release_uploaded_file_pin(
|
||||
dir: &Path,
|
||||
artifact_id: &str,
|
||||
owner_id: &str,
|
||||
) -> Result<()> {
|
||||
validate_pending_owner_id(owner_id)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let path = record_path(dir, artifact_id)?;
|
||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
if stored.pending_owner_id.as_deref() != Some(owner_id) {
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
stored.pending_owner_id = None;
|
||||
let temp = dir.join(format!(".{artifact_id}.file.unpin.tmp"));
|
||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
||||
fs::rename(temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finalize_uploaded_file_binding(
|
||||
dir: &Path,
|
||||
artifact_id: &str,
|
||||
source_entry_id: &str,
|
||||
) -> Result<()> {
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let path = record_path(dir, artifact_id)?;
|
||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
if stored.source_entry_id.as_deref() != Some(source_entry_id) {
|
||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
||||
}
|
||||
if stored.pending_owner_id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
stored.pending_owner_id = None;
|
||||
let temp = dir.join(format!(".{artifact_id}.file.finalize.tmp"));
|
||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
||||
fs::rename(temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn bind_uploaded_file(
|
||||
dir: &Path,
|
||||
reference: &UploadedFileRef,
|
||||
@@ -479,6 +586,40 @@ pub(crate) fn copy_committed_uploaded_files(source_dir: &Path, target_dir: &Path
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_uploaded_file_pins(dir: &Path, live_owner_ids: &[String]) -> Result<u64> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(dir.join(".aggregate.lock"))?;
|
||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
||||
let mut reconciled = 0_u64;
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(artifact_id) = file_name.strip_suffix(".file.json") else {
|
||||
continue;
|
||||
};
|
||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
let Some(owner_id) = stored.pending_owner_id.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if live_owner_ids.iter().any(|live| live == owner_id) {
|
||||
continue;
|
||||
}
|
||||
stored.pending_owner_id = None;
|
||||
let temp = dir.join(format!(".{artifact_id}.file.reconcile.tmp"));
|
||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
||||
fs::rename(temp, path)?;
|
||||
reconciled = reconciled.saturating_add(1);
|
||||
}
|
||||
Ok(reconciled)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let aggregate_lock = fs::OpenOptions::new()
|
||||
@@ -499,7 +640,7 @@ pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
|
||||
continue;
|
||||
}
|
||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
if stored.source_entry_id.is_none() {
|
||||
if stored.source_entry_id.is_none() && stored.pending_owner_id.is_none() {
|
||||
fs::remove_file(path)?;
|
||||
removed = removed
|
||||
.checked_add(1)
|
||||
@@ -523,7 +664,7 @@ pub(crate) fn delete_uploaded_file(dir: &Path, artifact_id: &str) -> Result<bool
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if stored.source_entry_id.is_some() {
|
||||
if stored.source_entry_id.is_some() || stored.pending_owner_id.is_some() {
|
||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
||||
}
|
||||
match fs::remove_file(path) {
|
||||
|
||||
@@ -63,6 +63,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
|
||||
@@ -682,6 +684,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 +856,7 @@ mod tests {
|
||||
target: std::path::Path::new("/tmp/delegated").into(),
|
||||
permission: "write".into(),
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
};
|
||||
store
|
||||
.set_spawned_children(
|
||||
|
||||
@@ -3,7 +3,7 @@ mod common;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::interceptor::{Interceptor, TurnEndAction};
|
||||
use agen::interceptor::{AssistantTurnEndContext, Interceptor, InterceptorResult, TurnEndAction};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::types::{Item, RequestConfig};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
@@ -100,8 +100,11 @@ struct PausePolicy;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PausePolicy {
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
TurnEndAction::Pause
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
Ok(TurnEndAction::Pause)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +353,8 @@ async fn session_run_with_tool_call() {
|
||||
async fn session_resume_after_pause() {
|
||||
let (_dir, store) = make_store();
|
||||
|
||||
// First run: tool call with pause policy → Paused
|
||||
// First terminal assistant response requests a tool; the assistant-turn
|
||||
// interceptor pauses before the Engine enters the tool phase.
|
||||
let client = MockLlmClient::with_responses(tool_call_events());
|
||||
let mut worker = TestWorker::new(Engine::new(client));
|
||||
worker.register_tool(weather_tool_definition());
|
||||
@@ -386,7 +390,7 @@ async fn session_resume_after_pause() {
|
||||
// Restore state and verify
|
||||
let state = session_store::restore(&store, sid, segid).unwrap();
|
||||
assert!(state.last_run_interrupted);
|
||||
assert_eq!(state.active_run_turn_count, Some(2));
|
||||
assert_eq!(state.active_run_turn_count, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -318,7 +318,11 @@ impl StandaloneHost {
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
|
||||
let _ = self.handle.send(Method::Shutdown).await;
|
||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
u64::MAX,
|
||||
&self.handle.shared_state.snapshot(),
|
||||
);
|
||||
let _ = self.handle.send(Method::Shutdown { command }).await;
|
||||
let Some(shutdown) = self.shutdown.take() else {
|
||||
self.retain_lease();
|
||||
return Err(StandaloneShutdownError::ConfirmationLost);
|
||||
@@ -500,7 +504,11 @@ fn active_pointer(
|
||||
}
|
||||
|
||||
async fn stop_started_worker(started: BootstrappedWorker) {
|
||||
let _ = started.handle.send(Method::Shutdown).await;
|
||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
u64::MAX,
|
||||
&started.handle.shared_state.snapshot(),
|
||||
);
|
||||
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() {
|
||||
|
||||
@@ -99,7 +99,10 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
|
||||
let mut protocol_client = host.connect();
|
||||
|
||||
protocol_client
|
||||
.send(&Method::run_text("read the probe"))
|
||||
.send(&Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"read the probe",
|
||||
))
|
||||
.await
|
||||
.expect("submit input");
|
||||
|
||||
@@ -336,11 +339,15 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
|
||||
let worker_id = host.worker_id();
|
||||
let mut protocol_client = host.connect();
|
||||
protocol_client
|
||||
.send(&Method::run_text("first request"))
|
||||
.send(&Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"first request",
|
||||
))
|
||||
.await?;
|
||||
wait_for_run_end(&mut protocol_client).await?;
|
||||
protocol_client
|
||||
.send(&Method::Notify {
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message: "persisted notification".to_string(),
|
||||
auto_run: true,
|
||||
})
|
||||
@@ -394,7 +401,10 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
|
||||
assert!(snapshot.contains("persisted notification"), "{snapshot}");
|
||||
|
||||
protocol_client
|
||||
.send(&Method::run_text("continue after restore"))
|
||||
.send(&Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"continue after restore",
|
||||
))
|
||||
.await?;
|
||||
wait_for_run_end(&mut protocol_client).await?;
|
||||
let request = second_inspection
|
||||
|
||||
@@ -118,6 +118,7 @@ impl Tool for BashTool {
|
||||
command: params.command,
|
||||
timeout_secs,
|
||||
output_limit: INLINE_BYTE_BUDGET,
|
||||
cwd: None,
|
||||
spill_dir: Some(self.output_dir.clone()),
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
})
|
||||
@@ -299,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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+283
-192
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
|
||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
|
||||
RewindTarget, RunResult, Segment, WorkerStatus,
|
||||
RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
|
||||
};
|
||||
|
||||
use crate::block::{
|
||||
@@ -102,23 +102,6 @@ struct RollbackSubmitState {
|
||||
turn_before: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct QueuedInput {
|
||||
segments: Vec<Segment>,
|
||||
preview: String,
|
||||
}
|
||||
|
||||
impl QueuedInput {
|
||||
fn new(segments: Vec<Segment>) -> Self {
|
||||
let preview = Segment::flatten_to_text(&segments);
|
||||
Self { segments, preview }
|
||||
}
|
||||
|
||||
pub fn preview(&self) -> &str {
|
||||
&self.preview
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposerInputHistory {
|
||||
entries: VecDeque<Vec<Segment>>,
|
||||
browse: Option<ComposerInputHistoryBrowse>,
|
||||
@@ -242,8 +225,10 @@ pub struct WorkerViewTab {
|
||||
pub struct App {
|
||||
pub worker_name: String,
|
||||
pub connected: bool,
|
||||
/// Last controller status reported by the Worker. Drives the status line
|
||||
/// and Ctrl-key routing; do not infer this solely from replayed history.
|
||||
/// Latest authoritative revisioned live execution state.
|
||||
pub worker_state: WorkerStateSnapshot,
|
||||
next_command_id: u64,
|
||||
/// Derived Runtime-catalog compatibility projection used by existing UI.
|
||||
pub worker_status: WorkerStatus,
|
||||
/// True while the Worker is in `WorkerStatus::Running`.
|
||||
pub running: bool,
|
||||
@@ -272,7 +257,7 @@ pub struct App {
|
||||
/// Current transient actionbar notice. Notices are local UI state only:
|
||||
/// they are never appended to transcript/session history or LLM context.
|
||||
actionbar_notice: Option<ActionbarNotice>,
|
||||
/// Normal composer input that is submitted as `Method::Run`.
|
||||
/// Normal composer input that is submitted as `Method::Submit`.
|
||||
pub input: InputBuffer,
|
||||
/// Separate command-line input. It is never submitted as a user message.
|
||||
pub command_input: InputBuffer,
|
||||
@@ -333,9 +318,8 @@ pub struct App {
|
||||
/// Top entry index of the task pane's visible window. Clamped on
|
||||
/// render so it never points past the end of the list.
|
||||
pub task_pane_scroll: usize,
|
||||
/// TUI-local FIFO of user inputs submitted while the Worker is already running.
|
||||
/// Entries have not been sent to the Worker yet, so they remain editable/cancellable locally.
|
||||
queued_inputs: VecDeque<QueuedInput>,
|
||||
/// Authoritative WorkerSession FIFO summary received from snapshot/live events.
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot,
|
||||
/// TUI-local readline-style composer input history. This is intentionally
|
||||
/// client-side only: recalled entries are plain drafts until submitted again.
|
||||
input_history: ComposerInputHistory,
|
||||
@@ -355,6 +339,8 @@ impl App {
|
||||
Self {
|
||||
worker_name,
|
||||
connected: false,
|
||||
worker_state: WorkerStateSnapshot::initial(1),
|
||||
next_command_id: 1,
|
||||
worker_status: WorkerStatus::Idle,
|
||||
running: false,
|
||||
paused: false,
|
||||
@@ -395,7 +381,7 @@ impl App {
|
||||
text_selection: TextSelectionState::default(),
|
||||
task_pane_open: false,
|
||||
task_pane_scroll: 0,
|
||||
queued_inputs: VecDeque::new(),
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
input_history: ComposerInputHistory::new(),
|
||||
input_history_store: None,
|
||||
pending_submit_rollback: None,
|
||||
@@ -763,23 +749,40 @@ impl App {
|
||||
if self.paused {
|
||||
self.input_history.cancel_browse();
|
||||
self.input.clear();
|
||||
return Some(Method::Resume);
|
||||
let command = self.next_command_envelope();
|
||||
return Some(Method::Resume { command });
|
||||
}
|
||||
return None;
|
||||
}
|
||||
self.record_input_history(segments.clone());
|
||||
if self.running {
|
||||
self.queued_inputs.push_back(QueuedInput::new(segments));
|
||||
self.input.clear();
|
||||
self.completion = None;
|
||||
return None;
|
||||
}
|
||||
self.input.clear();
|
||||
Some(self.method_for_run(segments))
|
||||
}
|
||||
|
||||
pub fn submit_notify_input(&mut self) -> Option<Method> {
|
||||
let segments = self.input.submit_segments();
|
||||
if segments_are_blank(&segments) {
|
||||
return None;
|
||||
}
|
||||
if segments
|
||||
.iter()
|
||||
.any(|segment| matches!(segment, Segment::UploadedFile { .. }))
|
||||
{
|
||||
self.push_error("Notify accepts text only; remove attachments or queue a Submit.");
|
||||
return None;
|
||||
}
|
||||
let message = Segment::flatten_to_text(&segments);
|
||||
self.record_input_history(segments);
|
||||
self.input.clear();
|
||||
Some(Method::Notify {
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message,
|
||||
auto_run: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn restore_unsent_run(&mut self, method: &Method) {
|
||||
let Method::Run { input } = method else {
|
||||
let Method::Submit { input, .. } = method else {
|
||||
return;
|
||||
};
|
||||
self.pending_submit_rollback = None;
|
||||
@@ -787,8 +790,9 @@ impl App {
|
||||
self.input.replace_with_segments(input);
|
||||
self.completion = None;
|
||||
} else {
|
||||
self.queued_inputs
|
||||
.push_front(QueuedInput::new(input.clone()));
|
||||
self.push_error(
|
||||
"Submit transport failed; current Composer was preserved and the unsent input was not queued.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,7 +808,10 @@ impl App {
|
||||
block_start: self.blocks.len(),
|
||||
turn_before: self.turn_index,
|
||||
});
|
||||
Method::Run { input: segments }
|
||||
Method::Submit {
|
||||
submission_request_id: protocol::new_submission_request_id(),
|
||||
input: segments,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_input_history(&mut self, segments: Vec<Segment>) {
|
||||
@@ -825,7 +832,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn queued_input_count(&self) -> usize {
|
||||
self.queued_inputs.len()
|
||||
self.pending_submissions.submissions.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -910,36 +917,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn continue_pending_method(&self) -> Option<Method> {
|
||||
Some(Method::ContinuePending {
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
expected_head_id: self.pending_submissions.head_id.clone()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_pending_method(&self) -> Method {
|
||||
Method::ClearPendingSubmissions {
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_pending_method(&self, submission_id: String) -> Method {
|
||||
Method::CancelPendingSubmission {
|
||||
submission_id,
|
||||
expected_revision: self.pending_submissions.revision,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_queued_input_preview(&self) -> Option<&str> {
|
||||
self.queued_inputs.front().map(QueuedInput::preview)
|
||||
}
|
||||
|
||||
pub fn clear_queued_inputs(&mut self) -> usize {
|
||||
let cleared = self.queued_inputs.len();
|
||||
self.queued_inputs.clear();
|
||||
cleared
|
||||
}
|
||||
|
||||
pub fn restore_next_queued_input_to_composer(&mut self) -> bool {
|
||||
if self.queued_inputs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !self.input.is_empty() {
|
||||
self.push_error("Composer is not empty; clear it before editing queued input.");
|
||||
return false;
|
||||
}
|
||||
let Some(queued) = self.queued_inputs.pop_front() else {
|
||||
return false;
|
||||
};
|
||||
self.input_history.cancel_browse();
|
||||
self.input.replace_with_segments(&queued.segments);
|
||||
self.completion = None;
|
||||
true
|
||||
}
|
||||
|
||||
fn pop_next_queued_run(&mut self) -> Option<Method> {
|
||||
let queued = self.queued_inputs.pop_front()?;
|
||||
Some(self.method_for_run(queued.segments))
|
||||
self.pending_submissions
|
||||
.submissions
|
||||
.first()
|
||||
.map(|submission| submission.submission_id.as_str())
|
||||
}
|
||||
|
||||
pub fn clear_actionbar_notice(&mut self) {
|
||||
@@ -1117,12 +1119,42 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_command_envelope(&mut self) -> WorkerCommandEnvelope {
|
||||
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);
|
||||
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}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
|
||||
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::SubmissionAccepted { .. } => {}
|
||||
Event::SubmissionRejected { message, .. } => self.push_error(message),
|
||||
Event::PendingSubmissionsChanged { pending } => {
|
||||
self.pending_submissions = pending;
|
||||
}
|
||||
Event::UserMessage { segments } => {
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
@@ -1148,18 +1180,14 @@ impl App {
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
Event::TurnStart { .. } => {
|
||||
self.set_worker_status(WorkerStatus::Running);
|
||||
self.run_requests += 1;
|
||||
self.current_tool = None;
|
||||
self.latest_llm_wait_event = None;
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
Event::InvokeStart { .. } => {
|
||||
self.set_worker_status(WorkerStatus::Running);
|
||||
}
|
||||
Event::InvokeStart { .. } => {}
|
||||
// UI consumers of per-attempt LlmCall semantics remain out of scope;
|
||||
// the run-level status starts at InvokeStart and TurnStart counts each
|
||||
// LLM request within that run.
|
||||
// authoritative run state comes only from WorkerStateSnapshot.
|
||||
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
|
||||
self.latest_llm_wait_event = None;
|
||||
}
|
||||
@@ -1366,15 +1394,7 @@ impl App {
|
||||
output_tokens: self.run_output_tokens,
|
||||
});
|
||||
self.pending_submit_rollback = None;
|
||||
self.reset_run_state(match result {
|
||||
RunResult::Paused => WorkerStatus::Paused,
|
||||
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
|
||||
WorkerStatus::Idle
|
||||
}
|
||||
});
|
||||
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
|
||||
return self.pop_next_queued_run();
|
||||
}
|
||||
self.reset_run_state();
|
||||
}
|
||||
}
|
||||
Event::CompactStart { .. } => {
|
||||
@@ -1444,14 +1464,15 @@ impl App {
|
||||
Event::Snapshot {
|
||||
session,
|
||||
greeting,
|
||||
status,
|
||||
state,
|
||||
in_flight,
|
||||
internal_workers,
|
||||
} => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.pending_submissions = session.pending_submissions.clone();
|
||||
self.restore_snapshot(&session, greeting, in_flight);
|
||||
self.replace_internal_worker_snapshots(internal_workers);
|
||||
self.set_worker_status(status);
|
||||
self.apply_worker_state_snapshot(&state);
|
||||
}
|
||||
Event::InternalWorker {
|
||||
worker,
|
||||
@@ -1461,9 +1482,12 @@ impl App {
|
||||
Event::InternalWorkerRemoved { worker, revision } => {
|
||||
self.remove_internal_worker(worker, revision)
|
||||
}
|
||||
Event::Status { status } => {
|
||||
Event::WorkerState { snapshot } => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.set_worker_status(status);
|
||||
self.apply_worker_state_snapshot(&snapshot);
|
||||
}
|
||||
Event::CommandAcknowledged { acknowledgement } => {
|
||||
self.apply_worker_state_snapshot(&acknowledgement.state);
|
||||
}
|
||||
// Command telemetry is an operational Web Console surface. The
|
||||
// TUI continues to render the final Bash ToolResult from history.
|
||||
@@ -1503,7 +1527,7 @@ impl App {
|
||||
};
|
||||
self.completion = None;
|
||||
self.close_rewind_picker();
|
||||
self.reset_run_state(self.worker_status);
|
||||
self.reset_run_state();
|
||||
let mut message = if restored_composer {
|
||||
format!(
|
||||
"Rewound session: discarded {} log entries; restored selected input to composer.",
|
||||
@@ -1551,8 +1575,7 @@ impl App {
|
||||
None
|
||||
}
|
||||
|
||||
fn reset_run_state(&mut self, status: WorkerStatus) {
|
||||
self.set_worker_status(status);
|
||||
fn reset_run_state(&mut self) {
|
||||
self.run_requests = 0;
|
||||
self.run_upload_tokens = 0;
|
||||
self.run_output_tokens = 0;
|
||||
@@ -1582,7 +1605,7 @@ impl App {
|
||||
"Rolled back empty assistant turn; no local submitted input was available to restore."
|
||||
.to_owned()
|
||||
};
|
||||
self.reset_run_state(WorkerStatus::Idle);
|
||||
self.reset_run_state();
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Warn,
|
||||
source: AlertSource::Worker,
|
||||
@@ -2026,12 +2049,18 @@ impl App {
|
||||
self.input_mode = CommandInputMode::Composer;
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
if let Some(Method::ListRewindTargets) = result.method.as_ref() {
|
||||
let mut method = result.method;
|
||||
if let Some(Method::Compact { .. }) = method {
|
||||
method = Some(Method::Compact {
|
||||
command: self.next_command_envelope(),
|
||||
});
|
||||
}
|
||||
if let Some(Method::ListRewindTargets) = method.as_ref() {
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
}
|
||||
result.method
|
||||
method
|
||||
}
|
||||
|
||||
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
|
||||
@@ -2681,7 +2710,10 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![],
|
||||
},
|
||||
input: vec![Segment::text("selected rewind input")],
|
||||
summary: summary(3),
|
||||
});
|
||||
@@ -2700,7 +2732,10 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![],
|
||||
},
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(1),
|
||||
});
|
||||
@@ -2743,7 +2778,10 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![],
|
||||
},
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(2),
|
||||
});
|
||||
@@ -2752,8 +2790,8 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
assert!(!blocks_contain(&app, "stale tail after rewind"));
|
||||
|
||||
app.handle_worker_event(Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
app.handle_worker_event(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Idle.into(),
|
||||
});
|
||||
app.handle_worker_event(Event::TextDelta {
|
||||
text: "new live tail after status".into(),
|
||||
@@ -2877,7 +2915,7 @@ mod composer_history_persistence_tests {
|
||||
path: "src/lib.rs".into(),
|
||||
},
|
||||
]);
|
||||
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
|
||||
|
||||
let mut reloaded = App::new_with_input_history_store("test".into(), store);
|
||||
assert!(reloaded.browse_input_history_older());
|
||||
@@ -2958,7 +2996,7 @@ mod composer_history_persistence_tests {
|
||||
app.insert_char(c);
|
||||
}
|
||||
match app.submit_input() {
|
||||
Some(Method::Run { input }) => input,
|
||||
Some(Method::Submit { input, .. }) => input,
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -3424,72 +3462,44 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_submit_is_queued_locally_and_clears_composer() {
|
||||
fn running_submit_is_sent_to_the_worker_and_not_queued_locally() {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
insert_text(&mut app, "queued turn");
|
||||
|
||||
assert!(app.submit_input().is_none());
|
||||
let method = app.submit_input();
|
||||
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
assert_eq!(app.next_queued_input_preview(), Some("queued turn"));
|
||||
assert!(matches!(method, Some(Method::Submit { .. })));
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
assert_eq!(input_text(&app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_run_auto_sends_next_queued_input() {
|
||||
fn pending_submission_projection_is_worker_authoritative() {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
insert_text(&mut app, "next turn");
|
||||
assert!(app.submit_input().is_none());
|
||||
|
||||
let method = app.handle_worker_event(Event::RunEnd {
|
||||
result: RunResult::Finished,
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 3,
|
||||
notification_count: 0,
|
||||
head_id: Some("submission-1".into()),
|
||||
submissions: vec![protocol::PendingSubmissionSummary {
|
||||
submission_id: "submission-1".into(),
|
||||
accepted_at_ms: 7,
|
||||
segment_count: 2,
|
||||
byte_len: 42,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
match method {
|
||||
Some(Method::Run { input }) => {
|
||||
assert_eq!(Segment::flatten_to_text(&input), "next turn");
|
||||
}
|
||||
other => panic!("expected queued Run, got {other:?}"),
|
||||
}
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_reached_run_auto_sends_next_queued_input() {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
insert_text(&mut app, "next after limit");
|
||||
assert!(app.submit_input().is_none());
|
||||
|
||||
let method = app.handle_worker_event(Event::RunEnd {
|
||||
result: RunResult::LimitReached,
|
||||
});
|
||||
|
||||
match method {
|
||||
Some(Method::Run { input }) => {
|
||||
assert_eq!(Segment::flatten_to_text(&input), "next after limit");
|
||||
}
|
||||
other => panic!("expected queued Run, got {other:?}"),
|
||||
}
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
|
||||
for result in [RunResult::Paused, RunResult::RolledBack] {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
insert_text(&mut app, "held turn");
|
||||
assert!(app.submit_input().is_none());
|
||||
|
||||
let method = app.handle_worker_event(Event::RunEnd { result });
|
||||
|
||||
assert!(method.is_none());
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
assert_eq!(app.next_queued_input_preview(), Some("held turn"));
|
||||
}
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
assert_eq!(app.next_queued_input_preview(), Some("submission-1"));
|
||||
assert!(
|
||||
app.handle_worker_event(Event::RunEnd {
|
||||
result: RunResult::Finished,
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3497,25 +3507,7 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Paused);
|
||||
|
||||
assert!(matches!(app.submit_input(), Some(Method::Resume)));
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_input_can_be_restored_to_composer_or_cleared() {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
insert_text(&mut app, "edit me");
|
||||
assert!(app.submit_input().is_none());
|
||||
|
||||
assert!(app.restore_next_queued_input_to_composer());
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
assert_eq!(input_text(&app), "edit me");
|
||||
|
||||
app.input.clear();
|
||||
insert_text(&mut app, "clear me");
|
||||
assert!(app.submit_input().is_none());
|
||||
assert_eq!(app.clear_queued_inputs(), 1);
|
||||
assert!(matches!(app.submit_input(), Some(Method::Resume { .. })));
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
}
|
||||
|
||||
@@ -3530,7 +3522,7 @@ mod completion_flow_tests {
|
||||
app.insert_char(c);
|
||||
}
|
||||
match app.submit_input() {
|
||||
Some(Method::Run { input }) => input,
|
||||
Some(Method::Submit { input, .. }) => input,
|
||||
other => panic!("expected Run, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -3570,7 +3562,7 @@ mod completion_flow_tests {
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: public_session(vec![session_start_value]),
|
||||
status: WorkerStatus::Running,
|
||||
state: test_worker_state(WorkerStatus::Running),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
@@ -3581,6 +3573,90 @@ mod completion_flow_tests {
|
||||
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_events_do_not_infer_foreground_worker_state() {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::TurnStart { turn: 1 });
|
||||
app.handle_worker_event(Event::InvokeStart {
|
||||
kind: protocol::InvokeKind::UserSend,
|
||||
});
|
||||
app.handle_worker_event(Event::RunEnd {
|
||||
result: RunResult::Paused,
|
||||
});
|
||||
assert_eq!(app.worker_state.state, protocol::WorkerState::Idle);
|
||||
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,
|
||||
)),
|
||||
last_command_id: 0,
|
||||
};
|
||||
app.handle_worker_event(Event::WorkerState {
|
||||
snapshot: running.clone(),
|
||||
});
|
||||
app.handle_worker_event(Event::RunEnd {
|
||||
result: RunResult::Finished,
|
||||
});
|
||||
assert_eq!(app.worker_state, running);
|
||||
assert_eq!(app.worker_status, WorkerStatus::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_state_events_and_acknowledgements_share_monotonic_application() {
|
||||
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,
|
||||
)),
|
||||
last_command_id: 2,
|
||||
};
|
||||
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 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 {
|
||||
command_id: 3,
|
||||
command: protocol::WorkerCommandKind::Pause,
|
||||
disposition: protocol::WorkerCommandDisposition::Accepted,
|
||||
state: paused.clone(),
|
||||
},
|
||||
});
|
||||
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]
|
||||
fn snapshot_replaces_live_error_with_one_durable_run_error_block() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -3588,8 +3664,8 @@ mod completion_flow_tests {
|
||||
code: ErrorCode::ProviderError,
|
||||
message: "provider unavailable".into(),
|
||||
});
|
||||
app.handle_worker_event(Event::Status {
|
||||
status: WorkerStatus::Idle,
|
||||
app.handle_worker_event(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Idle.into(),
|
||||
});
|
||||
|
||||
let live_errors = app
|
||||
@@ -3614,7 +3690,7 @@ mod completion_flow_tests {
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
|
||||
status: WorkerStatus::Idle,
|
||||
state: test_worker_state(WorkerStatus::Idle),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
@@ -3675,9 +3751,10 @@ mod completion_flow_tests {
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Running,
|
||||
state: test_worker_state(WorkerStatus::Running),
|
||||
in_flight: InFlightSnapshot {
|
||||
blocks: vec![
|
||||
InFlightBlock::Thinking {
|
||||
@@ -3783,6 +3860,7 @@ mod completion_flow_tests {
|
||||
revision,
|
||||
status: WorkerStatus::Idle,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
in_flight: protocol::InFlightSnapshot::default(),
|
||||
@@ -4000,9 +4078,10 @@ mod completion_flow_tests {
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: test_worker_state(WorkerStatus::Idle),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
@@ -4051,9 +4130,10 @@ mod completion_flow_tests {
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: test_worker_state(WorkerStatus::Idle),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: vec![InternalWorkerSnapshot {
|
||||
worker: InternalWorkerRef {
|
||||
@@ -4064,6 +4144,7 @@ mod completion_flow_tests {
|
||||
},
|
||||
revision: 4,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Running,
|
||||
@@ -4200,6 +4281,13 @@ mod completion_flow_tests {
|
||||
.count()
|
||||
}
|
||||
|
||||
fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot {
|
||||
let mut snapshot = WorkerStateSnapshot::from(status);
|
||||
snapshot.execution_generation = 1;
|
||||
snapshot.revision = 1;
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn test_greeting() -> protocol::Greeting {
|
||||
protocol::Greeting {
|
||||
worker_name: "test".into(),
|
||||
@@ -4222,10 +4310,11 @@ mod completion_flow_tests {
|
||||
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting,
|
||||
status: WorkerStatus::Idle,
|
||||
state: test_worker_state(WorkerStatus::Idle),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
@@ -4424,7 +4513,7 @@ mod completion_flow_tests {
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: public_session(assistant_item_entries),
|
||||
status: WorkerStatus::Running,
|
||||
state: test_worker_state(WorkerStatus::Running),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
@@ -4437,23 +4526,23 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_history_records_queued_inputs_and_suppresses_consecutive_duplicates() {
|
||||
fn input_history_records_running_submits_and_suppresses_consecutive_duplicates() {
|
||||
let mut app = App::new("test".into());
|
||||
app.running = true;
|
||||
|
||||
for c in "repeat".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(app.submit_input().is_none());
|
||||
assert!(app.submit_input().is_some());
|
||||
assert_eq!(app.input_history_len(), 1);
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
|
||||
for c in "repeat".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(app.submit_input().is_none());
|
||||
assert!(app.submit_input().is_some());
|
||||
assert_eq!(app.input_history_len(), 1);
|
||||
assert_eq!(app.queued_input_count(), 2);
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
|
||||
app.insert_char(' ');
|
||||
assert!(app.submit_input().is_none());
|
||||
@@ -4481,7 +4570,7 @@ mod completion_flow_tests {
|
||||
},
|
||||
];
|
||||
app.input.replace_with_segments(&original);
|
||||
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
|
||||
|
||||
assert!(app.browse_input_history_older());
|
||||
assert_eq!(app.input.submit_segments(), original);
|
||||
@@ -4493,7 +4582,7 @@ mod completion_flow_tests {
|
||||
for c in "sent".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
|
||||
|
||||
for c in "draft".chars() {
|
||||
app.insert_char(c);
|
||||
@@ -4511,7 +4600,7 @@ mod completion_flow_tests {
|
||||
for c in "sent".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
|
||||
|
||||
assert!(app.browse_input_history_older());
|
||||
assert!(app.input_history_is_browsing());
|
||||
@@ -4528,17 +4617,19 @@ mod completion_flow_tests {
|
||||
for c in "first".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
|
||||
for c in "second".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
|
||||
|
||||
assert!(app.browse_input_history_older());
|
||||
assert!(app.browse_input_history_older());
|
||||
let method = app.submit_input();
|
||||
match method {
|
||||
Some(Method::Run { input }) => assert_eq!(Segment::flatten_to_text(&input), "first"),
|
||||
Some(Method::Submit { input, .. }) => {
|
||||
assert_eq!(Segment::flatten_to_text(&input), "first")
|
||||
}
|
||||
other => panic!("expected recalled run, got {other:?}"),
|
||||
}
|
||||
assert_eq!(app.input_history_len(), 3);
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
use client::{
|
||||
BackendCreateWorkerRequest, BackendWorkerLaunchOptions, BackendWorkerLaunchProfileCandidate,
|
||||
BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchTarget, create_backend_worker,
|
||||
get_backend_worker_launch_options,
|
||||
};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
|
||||
use crate::backend_workspace_picker::select_backend_workspace;
|
||||
use crate::console;
|
||||
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Field {
|
||||
Name,
|
||||
Runtime,
|
||||
Profile,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Name => Self::Runtime,
|
||||
Self::Runtime => Self::Profile,
|
||||
Self::Profile => Self::Name,
|
||||
}
|
||||
}
|
||||
|
||||
fn previous(self) -> Self {
|
||||
match self {
|
||||
Self::Name => Self::Profile,
|
||||
Self::Runtime => Self::Name,
|
||||
Self::Profile => Self::Runtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Selection {
|
||||
runtime_id: String,
|
||||
display_name: String,
|
||||
profile: String,
|
||||
}
|
||||
|
||||
struct FormState {
|
||||
field: Field,
|
||||
display_name: String,
|
||||
runtime_index: usize,
|
||||
profile_index: usize,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
fn new(options: &BackendWorkerLaunchOptions) -> Self {
|
||||
let runtime_index = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.position(runtime_supports_workdirless_creation)
|
||||
.unwrap_or(0);
|
||||
let profile_index = options
|
||||
.default_profile
|
||||
.as_deref()
|
||||
.and_then(|default| {
|
||||
options
|
||||
.profiles
|
||||
.iter()
|
||||
.position(|candidate| candidate.id == default)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
field: Field::Name,
|
||||
display_name: "Worker".to_string(),
|
||||
runtime_index,
|
||||
profile_index,
|
||||
status: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_runtime<'a>(
|
||||
&self,
|
||||
options: &'a BackendWorkerLaunchOptions,
|
||||
) -> Option<&'a BackendWorkerLaunchRuntimeOption> {
|
||||
options.runtimes.get(self.runtime_index)
|
||||
}
|
||||
|
||||
fn current_profile<'a>(
|
||||
&self,
|
||||
options: &'a BackendWorkerLaunchOptions,
|
||||
) -> Option<&'a BackendWorkerLaunchProfileCandidate> {
|
||||
options.profiles.get(self.profile_index)
|
||||
}
|
||||
|
||||
fn cycle_runtime(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
|
||||
self.runtime_index = cycle_index(self.runtime_index, options.runtimes.len(), delta);
|
||||
self.status.clear();
|
||||
}
|
||||
|
||||
fn cycle_profile(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
|
||||
self.profile_index = cycle_index(self.profile_index, options.profiles.len(), delta);
|
||||
self.status.clear();
|
||||
}
|
||||
|
||||
fn submit(&mut self, options: &BackendWorkerLaunchOptions) -> Option<Selection> {
|
||||
let display_name = self.display_name.trim();
|
||||
if display_name.is_empty() {
|
||||
self.status = "Worker name is required.".to_string();
|
||||
self.field = Field::Name;
|
||||
return None;
|
||||
}
|
||||
let Some(runtime) = self.current_runtime(options) else {
|
||||
self.status = "No Runtime is available in this Workspace.".to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
};
|
||||
if !runtime.worker_creation_available {
|
||||
self.status = "The selected Runtime cannot create Workers right now.".to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
if runtime.working_directory_required {
|
||||
self.status =
|
||||
"The selected Runtime requires a workdir; this launch flow does not select one yet."
|
||||
.to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
let Some(profile) = self.current_profile(options) else {
|
||||
self.status = "No Worker profile is available.".to_string();
|
||||
self.field = Field::Profile;
|
||||
return None;
|
||||
};
|
||||
Some(Selection {
|
||||
runtime_id: runtime.runtime_id.clone(),
|
||||
display_name: display_name.to_string(),
|
||||
profile: profile.id.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(mut target: BackendWorkerLaunchTarget) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if target.workspace_id().is_none() {
|
||||
let Some(workspace) = select_backend_workspace(&target.base_url).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
target.select_workspace(workspace);
|
||||
}
|
||||
|
||||
let options = get_backend_worker_launch_options(&target).await?;
|
||||
let Some(selection) = select_worker(&options)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let request = request_from_selection(selection);
|
||||
let created = create_backend_worker(&target, &request).await?;
|
||||
let runtime_target = target.runtime_target(created.runtime_id, created.worker_id)?;
|
||||
console::run_backend_runtime(runtime_target).await
|
||||
}
|
||||
|
||||
fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
|
||||
BackendCreateWorkerRequest {
|
||||
runtime_id: selection.runtime_id,
|
||||
display_name: selection.display_name,
|
||||
profile: Some(selection.profile),
|
||||
initial_submit: Vec::new(),
|
||||
working_directory: None,
|
||||
ticket_assignment: None,
|
||||
control_operation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
const VIEWPORT_LINES: u16 = 14;
|
||||
|
||||
fn select_worker(
|
||||
options: &BackendWorkerLaunchOptions,
|
||||
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
|
||||
with_inline_terminal(VIEWPORT_LINES, |terminal| run_form(terminal, options))
|
||||
}
|
||||
|
||||
fn run_form(
|
||||
terminal: &mut InlineTerminal,
|
||||
options: &BackendWorkerLaunchOptions,
|
||||
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
|
||||
let mut state = FormState::new(options);
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| render(frame, &state, options))?;
|
||||
let event = event::read()?;
|
||||
let Event::Key(key) = event else {
|
||||
continue;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
return Ok(None);
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
state.field = state.field.next();
|
||||
state.status.clear();
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Up => {
|
||||
state.field = state.field.previous();
|
||||
state.status.clear();
|
||||
}
|
||||
KeyCode::Left => match state.field {
|
||||
Field::Runtime => state.cycle_runtime(options, -1),
|
||||
Field::Profile => state.cycle_profile(options, -1),
|
||||
Field::Name => {}
|
||||
},
|
||||
KeyCode::Right => match state.field {
|
||||
Field::Runtime => state.cycle_runtime(options, 1),
|
||||
Field::Profile => state.cycle_profile(options, 1),
|
||||
Field::Name => {}
|
||||
},
|
||||
KeyCode::Enter => {
|
||||
if let Some(selection) = state.submit(options) {
|
||||
return Ok(Some(selection));
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace if state.field == Field::Name => {
|
||||
state.display_name.pop();
|
||||
state.status.clear();
|
||||
}
|
||||
KeyCode::Char(character)
|
||||
if state.field == Field::Name
|
||||
&& !key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !character.is_control() =>
|
||||
{
|
||||
state.display_name.push(character);
|
||||
state.status.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(frame: &mut ratatui::Frame<'_>, state: &FormState, options: &BackendWorkerLaunchOptions) {
|
||||
let area = frame.area();
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
"New Backend Worker",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" Workspace: {}", options.workspace_id)),
|
||||
])),
|
||||
vertical[0],
|
||||
);
|
||||
|
||||
let focused = Style::default().fg(Color::Cyan);
|
||||
frame.render_widget(
|
||||
Paragraph::new(state.display_name.as_str()).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Name ")
|
||||
.border_style(if state.field == Field::Name {
|
||||
focused
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
),
|
||||
vertical[1],
|
||||
);
|
||||
|
||||
let runtime_text = state
|
||||
.current_runtime(options)
|
||||
.map(runtime_label)
|
||||
.unwrap_or_else(|| "No Runtime available".to_string());
|
||||
frame.render_widget(
|
||||
Paragraph::new(runtime_text).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(runtime_title(state, options))
|
||||
.border_style(if state.field == Field::Runtime {
|
||||
focused
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
),
|
||||
vertical[2],
|
||||
);
|
||||
|
||||
let profile_text = state
|
||||
.current_profile(options)
|
||||
.map(|profile| {
|
||||
if profile.description.is_empty() {
|
||||
profile.label.clone()
|
||||
} else {
|
||||
format!("{} — {}", profile.label, profile.description)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "No profile available".to_string());
|
||||
frame.render_widget(
|
||||
Paragraph::new(profile_text).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(profile_title(state, options))
|
||||
.border_style(if state.field == Field::Profile {
|
||||
focused
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
),
|
||||
vertical[3],
|
||||
);
|
||||
|
||||
let status = if state.status.is_empty() {
|
||||
"Tab/↑/↓: field ←/→: choice Enter: create Esc/Ctrl-C: cancel"
|
||||
} else {
|
||||
state.status.as_str()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(status)
|
||||
.style(if state.status.is_empty() {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default().fg(Color::Yellow)
|
||||
})
|
||||
.wrap(Wrap { trim: true }),
|
||||
vertical[4],
|
||||
);
|
||||
|
||||
if state.field == Field::Name {
|
||||
let max_cursor = vertical[1].width.saturating_sub(2) as usize;
|
||||
frame.set_cursor_position((
|
||||
vertical[1].x + 1 + state.display_name.chars().count().min(max_cursor) as u16,
|
||||
vertical[1].y + 1,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
|
||||
if options.runtimes.is_empty() {
|
||||
" Runtime ".to_string()
|
||||
} else {
|
||||
format!(
|
||||
" Runtime ({}/{}) ",
|
||||
state.runtime_index + 1,
|
||||
options.runtimes.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
|
||||
if options.profiles.is_empty() {
|
||||
" Profile ".to_string()
|
||||
} else {
|
||||
format!(
|
||||
" Profile ({}/{}) ",
|
||||
state.profile_index + 1,
|
||||
options.profiles.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
let availability = if !runtime.worker_creation_available {
|
||||
"unavailable"
|
||||
} else if runtime.working_directory_required {
|
||||
"workdir required"
|
||||
} else {
|
||||
"no workdir"
|
||||
};
|
||||
format!(
|
||||
"{} [{}] — {availability}",
|
||||
runtime.display_name, runtime.runtime_id
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool {
|
||||
runtime.worker_creation_available && !runtime.working_directory_required
|
||||
}
|
||||
|
||||
fn cycle_index(current: usize, len: usize, delta: isize) -> usize {
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
(current as isize + delta).rem_euclid(len as isize) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use client::{BackendDiagnostic, BackendWorkerLaunchOptions};
|
||||
|
||||
fn options() -> BackendWorkerLaunchOptions {
|
||||
BackendWorkerLaunchOptions {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
runtimes: vec![
|
||||
BackendWorkerLaunchRuntimeOption {
|
||||
runtime_id: "external".to_string(),
|
||||
display_name: "External".to_string(),
|
||||
built_in: false,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: true,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
BackendWorkerLaunchRuntimeOption {
|
||||
runtime_id: "embedded".to_string(),
|
||||
display_name: "Embedded".to_string(),
|
||||
built_in: true,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: false,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
],
|
||||
profiles: vec![
|
||||
BackendWorkerLaunchProfileCandidate {
|
||||
id: "builtin:default".to_string(),
|
||||
label: "Default".to_string(),
|
||||
description: String::new(),
|
||||
},
|
||||
BackendWorkerLaunchProfileCandidate {
|
||||
id: "builtin:coder".to_string(),
|
||||
label: "Coder".to_string(),
|
||||
description: "Ticket implementation".to_string(),
|
||||
},
|
||||
],
|
||||
default_profile: Some("builtin:coder".to_string()),
|
||||
repositories: Vec::new(),
|
||||
working_directories: Vec::new(),
|
||||
diagnostics: Vec::<BackendDiagnostic>::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_workdirless_runtime_and_backend_default_profile() {
|
||||
let options = options();
|
||||
let state = FormState::new(&options);
|
||||
assert_eq!(
|
||||
state.current_runtime(&options).unwrap().runtime_id,
|
||||
"embedded"
|
||||
);
|
||||
assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder");
|
||||
assert_eq!(state.display_name, "Worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_required_runtime_cannot_be_submitted() {
|
||||
let options = options();
|
||||
let mut state = FormState::new(&options);
|
||||
state.runtime_index = 0;
|
||||
assert_eq!(state.submit(&options), None);
|
||||
assert!(state.status.contains("requires a workdir"));
|
||||
assert_eq!(state.field, Field::Runtime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_builds_workdirless_create_request() {
|
||||
let request = request_from_selection(Selection {
|
||||
runtime_id: "embedded".to_string(),
|
||||
display_name: "Coder one".to_string(),
|
||||
profile: "builtin:coder".to_string(),
|
||||
});
|
||||
assert_eq!(request.runtime_id, "embedded");
|
||||
assert_eq!(request.display_name, "Coder one");
|
||||
assert_eq!(request.profile.as_deref(), Some("builtin:coder"));
|
||||
assert!(request.initial_submit.is_empty());
|
||||
assert!(request.working_directory.is_none());
|
||||
assert!(request.ticket_assignment.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -12,6 +13,7 @@ use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::backend_workspace_picker::select_backend_workspace;
|
||||
use crate::console;
|
||||
@@ -83,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
|
||||
};
|
||||
@@ -104,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())));
|
||||
@@ -235,9 +267,10 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
|
||||
layout[0],
|
||||
);
|
||||
|
||||
let column_widths = WorkerColumnWidths::from_workers(&state.workers);
|
||||
for (i, worker) in state.workers.iter().enumerate() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(row_line(worker, i == state.selected)),
|
||||
Paragraph::new(row_line(worker, &column_widths, i == state.selected)),
|
||||
layout[i + 1],
|
||||
);
|
||||
}
|
||||
@@ -272,7 +305,28 @@ fn picker_title(target: &BackendRuntimeListTarget) -> String {
|
||||
format!("backend workers workspace: {workspace} runtime: {runtime}")
|
||||
}
|
||||
|
||||
fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct WorkerColumnWidths {
|
||||
identity: usize,
|
||||
name: usize,
|
||||
state: usize,
|
||||
}
|
||||
|
||||
impl WorkerColumnWidths {
|
||||
fn from_workers(workers: &[BackendWorkerSummary]) -> Self {
|
||||
workers.iter().fold(Self::default(), |widths, worker| Self {
|
||||
identity: widths.identity.max(text_width(&short_worker_id(worker))),
|
||||
name: widths.name.max(text_width(worker_name(worker))),
|
||||
state: widths.state.max(text_width(&worker_state(worker))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn row_line(
|
||||
worker: &BackendWorkerSummary,
|
||||
widths: &WorkerColumnWidths,
|
||||
selected: bool,
|
||||
) -> Line<'static> {
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let id_style = if selected {
|
||||
Style::default()
|
||||
@@ -281,42 +335,70 @@ fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
let preview_style = if selected {
|
||||
let name_style = if selected {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let label = if worker.label.is_empty() {
|
||||
worker.worker_id.as_str()
|
||||
} else {
|
||||
worker.label.as_str()
|
||||
};
|
||||
let profile = worker.profile.as_deref().unwrap_or("-");
|
||||
|
||||
Line::from(vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(short_worker_id(worker), id_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("[{}]", worker.state),
|
||||
state_style(worker.state.as_str()),
|
||||
pad_column(&short_worker_id(worker), widths.identity),
|
||||
id_style,
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(pad_column(worker_name(worker), widths.name), name_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("profile:{profile}"),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
pad_column(&worker_state(worker), widths.state),
|
||||
state_style(worker_state_label(worker)),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
working_directory_text(worker),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(label.to_string(), preview_style),
|
||||
])
|
||||
}
|
||||
|
||||
fn worker_name(worker: &BackendWorkerSummary) -> &str {
|
||||
if !worker.label.is_empty() {
|
||||
worker.label.as_str()
|
||||
} else if !worker.display_name.is_empty() {
|
||||
worker.display_name.as_str()
|
||||
} else {
|
||||
worker.worker_id.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_state_label(worker: &BackendWorkerSummary) -> &str {
|
||||
match worker.worker_state.as_ref().map(|state| &state.state) {
|
||||
Some(protocol::WorkerState::Idle) => "idle",
|
||||
Some(protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Paused,
|
||||
))) => "paused",
|
||||
Some(protocol::WorkerState::Busy(_)) => "running",
|
||||
None if worker.state == "stopped" => "stopped",
|
||||
None => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_state(worker: &BackendWorkerSummary) -> String {
|
||||
format!("[{}]", worker_state_label(worker))
|
||||
}
|
||||
|
||||
fn text_width(value: &str) -> usize {
|
||||
UnicodeWidthStr::width(value)
|
||||
}
|
||||
|
||||
fn pad_column(value: &str, width: usize) -> String {
|
||||
format!(
|
||||
"{value}{}",
|
||||
" ".repeat(width.saturating_sub(text_width(value)))
|
||||
)
|
||||
}
|
||||
|
||||
fn state_style(state: &str) -> Style {
|
||||
match state {
|
||||
"running" | "idle" | "active" => Style::default()
|
||||
@@ -347,18 +429,15 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||
let Some(wd) = worker.working_directory.as_ref() else {
|
||||
return "wd:—".to_string();
|
||||
};
|
||||
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
|
||||
format!(
|
||||
"wd:{}:{} {} {}",
|
||||
wd.repository_key, wd.working_directory_id, wd.status, cleanliness
|
||||
)
|
||||
format!("wd:{}・{}", wd.repository_key, wd.working_directory_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use client::{
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
|
||||
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
|
||||
BackendWorkerWorkspaceSummary,
|
||||
};
|
||||
|
||||
@@ -378,7 +457,15 @@ mod tests {
|
||||
identity: "ws".to_string(),
|
||||
workspace_id: Some("ws".to_string()),
|
||||
},
|
||||
state: "running".to_string(),
|
||||
state: "idle".to_string(),
|
||||
worker_state: Some(protocol::WorkerStateSnapshot {
|
||||
execution_generation: 1,
|
||||
revision: 1,
|
||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||
protocol::WorkerRunState::Running,
|
||||
)),
|
||||
last_command_id: 0,
|
||||
}),
|
||||
last_seen_at: None,
|
||||
pinned: false,
|
||||
retention_state: String::new(),
|
||||
@@ -395,18 +482,159 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_row_matches_inline_picker_shape() {
|
||||
let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true);
|
||||
let text = row
|
||||
fn row_text(worker: &BackendWorkerSummary, widths: &WorkerColumnWidths) -> String {
|
||||
row_line(worker, widths, false)
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content)
|
||||
.collect::<String>();
|
||||
assert!(text.starts_with("▶ W-1"));
|
||||
assert!(text.contains("[running]"));
|
||||
assert!(text.contains("profile:default"));
|
||||
assert!(text.contains("wd:—"));
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn display_column(text: &str, value: &str) -> usize {
|
||||
let byte_offset = text.find(value).expect("value in rendered row");
|
||||
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"));
|
||||
worker.resource_key = "W-90".to_string();
|
||||
worker.display_name = "Coder".to_string();
|
||||
worker.label = "Coder · T-585".to_string();
|
||||
worker.state = "stopped".to_string();
|
||||
worker.worker_state = None;
|
||||
worker.working_directory = Some(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"working_directory_id": "001a06a9f0202000000",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "runtime_git_clone",
|
||||
"status": "active",
|
||||
"cleanliness": "clean"
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
let widths = WorkerColumnWidths::from_workers(std::slice::from_ref(&worker));
|
||||
let text = row_text(&worker, &widths);
|
||||
|
||||
assert_eq!(
|
||||
text,
|
||||
" W-90 Coder · T-585 [stopped] wd:main・001a06a9f0202000000"
|
||||
);
|
||||
assert!(!text.contains("profile:"));
|
||||
assert!(!text.contains("active clean"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_rows_align_identity_name_state_and_workdir_columns() {
|
||||
let mut short = worker("runtime-a", "worker-a", None);
|
||||
short.resource_key = "W-2".to_string();
|
||||
short.label = "Coder".to_string();
|
||||
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,
|
||||
});
|
||||
|
||||
let mut long = worker("runtime-a", "worker-b", None);
|
||||
long.resource_key = "W-100".to_string();
|
||||
long.label = "Longer worker · T-9".to_string();
|
||||
long.display_name = long.label.clone();
|
||||
long.state = "stopped".to_string();
|
||||
long.worker_state = None;
|
||||
|
||||
for worker in [&mut short, &mut long] {
|
||||
worker.working_directory = Some(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"working_directory_id": "workdir-1",
|
||||
"repository_key": "main",
|
||||
"materializer_kind": "runtime_git_clone",
|
||||
"status": "active"
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let workers = vec![short, long];
|
||||
let widths = WorkerColumnWidths::from_workers(&workers);
|
||||
let first = row_text(&workers[0], &widths);
|
||||
let second = row_text(&workers[1], &widths);
|
||||
|
||||
assert_eq!(
|
||||
display_column(&first, "Coder"),
|
||||
display_column(&second, "Longer")
|
||||
);
|
||||
assert_eq!(
|
||||
display_column(&first, "[idle]"),
|
||||
display_column(&second, "[stopped]")
|
||||
);
|
||||
assert_eq!(
|
||||
display_column(&first, "wd:main"),
|
||||
display_column(&second, "wd:main")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -409,7 +409,12 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.environment;
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
method: Some(Method::Compact),
|
||||
method: Some(Method::Compact {
|
||||
command: protocol::WorkerCommandEnvelope::for_snapshot(
|
||||
0,
|
||||
&protocol::WorkerStateSnapshot::initial(1),
|
||||
),
|
||||
}),
|
||||
diagnostics: vec![CommandDiagnostic::new("compact requested")],
|
||||
exit_command_mode: true,
|
||||
clear_input: true,
|
||||
@@ -483,7 +488,7 @@ mod tests {
|
||||
fn compact_command_returns_compact_method_not_run() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
let result = registry.dispatch("compact", &env());
|
||||
assert!(matches!(result.method, Some(Method::Compact)));
|
||||
assert!(matches!(result.method, Some(Method::Compact { .. })));
|
||||
assert!(result.exit_command_mode);
|
||||
assert!(result.clear_input);
|
||||
assert!(result.diagnostics[0].message.contains("compact requested"));
|
||||
|
||||
+177
-128
@@ -270,8 +270,8 @@ impl<T: Socket> ConsoleConnection<T> {
|
||||
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut prepared = method.clone();
|
||||
let carries_attachments =
|
||||
matches!(prepared, Method::Run { .. }) && !self.pending_attachments.is_empty();
|
||||
if let Method::Run { input } = &mut prepared {
|
||||
matches!(prepared, Method::Submit { .. }) && !self.pending_attachments.is_empty();
|
||||
if let Method::Submit { input, .. } = &mut prepared {
|
||||
input.extend(
|
||||
self.pending_attachments
|
||||
.iter()
|
||||
@@ -569,9 +569,10 @@ async fn run_e2e_rewind_fixture(
|
||||
app.connected = true;
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: WorkerStatus::Idle.into(),
|
||||
greeting: Greeting {
|
||||
worker_name: worker_name.clone(),
|
||||
cwd: workspace_root.display().to_string(),
|
||||
@@ -697,6 +698,7 @@ async fn run_e2e_rewind_fixture(
|
||||
if submitted_at.elapsed() >= apply_delay {
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
input: vec![Segment::text("rewind-live-refresh")],
|
||||
@@ -916,7 +918,7 @@ async fn run_loop<T: Socket>(
|
||||
}
|
||||
|
||||
fn attachment_command_path(method: &Method) -> Option<PathBuf> {
|
||||
let Method::Run { input } = method else {
|
||||
let Method::Submit { input, .. } = method else {
|
||||
return None;
|
||||
};
|
||||
let [Segment::Text { content }] = input.as_slice() else {
|
||||
@@ -927,7 +929,7 @@ fn attachment_command_path(method: &Method) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn is_clear_attachments_command(method: &Method) -> bool {
|
||||
let Method::Run { input } = method else {
|
||||
let Method::Submit { input, .. } = method else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
@@ -941,7 +943,7 @@ async fn send_console_method<T: Socket>(
|
||||
client: &mut ConsoleConnection<T>,
|
||||
method: &Method,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if matches!(method, Method::Run { .. }) && client.has_active_uploads() {
|
||||
if matches!(method, Method::Submit { .. }) && client.has_active_uploads() {
|
||||
app.restore_unsent_run(method);
|
||||
app.flash_actionbar_notice(
|
||||
"Attachment upload is still in progress; wait or use /clear-attachments.",
|
||||
@@ -953,7 +955,7 @@ async fn send_console_method<T: Socket>(
|
||||
}
|
||||
|
||||
let sends_attachments =
|
||||
matches!(method, Method::Run { .. }) && !client.pending_attachments.is_empty();
|
||||
matches!(method, Method::Submit { .. }) && !client.pending_attachments.is_empty();
|
||||
if let Err(error) = client.send(method).await {
|
||||
if sends_attachments {
|
||||
app.restore_unsent_run(method);
|
||||
@@ -1148,18 +1150,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
app.clear_command_input();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if c.eq_ignore_ascii_case(&'d') && alt && !ctrl && !app.is_command_mode() =>
|
||||
{
|
||||
Some(
|
||||
app.next_queued_input_preview()
|
||||
.map(str::to_owned)
|
||||
.map(|submission_id| app.cancel_pending_method(submission_id)),
|
||||
)
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if c.eq_ignore_ascii_case(&'n') && alt && !ctrl && !app.is_command_mode() =>
|
||||
{
|
||||
Some(app.submit_notify_input())
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
|
||||
{
|
||||
if app.restore_next_queued_input_to_composer() {
|
||||
Some(app.refresh_completion())
|
||||
} else {
|
||||
Some(None)
|
||||
}
|
||||
Some(app.continue_pending_method())
|
||||
}
|
||||
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
|
||||
app.clear_queued_inputs();
|
||||
Some(None)
|
||||
Some(Some(app.clear_pending_method()))
|
||||
}
|
||||
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
||||
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
|
||||
@@ -1427,14 +1438,15 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
|
||||
WorkerStatus::Running | WorkerStatus::Paused
|
||||
) {
|
||||
app.shutdown_confirm = None;
|
||||
app.clear_queued_inputs();
|
||||
return Some(Method::Cancel);
|
||||
let command = app.next_command_envelope();
|
||||
return Some(Method::Cancel { command });
|
||||
}
|
||||
if let Some(pressed_at) = app.shutdown_confirm
|
||||
&& pressed_at.elapsed() < CONFIRM_TIMEOUT
|
||||
{
|
||||
app.shutdown_confirm = None;
|
||||
return Some(Method::Shutdown);
|
||||
let command = app.next_command_envelope();
|
||||
return Some(Method::Shutdown { command });
|
||||
}
|
||||
app.shutdown_confirm = Some(std::time::Instant::now());
|
||||
app.flash_actionbar_notice(
|
||||
@@ -1450,8 +1462,8 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
|
||||
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
|
||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||
if app.worker_status == WorkerStatus::Running {
|
||||
app.clear_queued_inputs();
|
||||
return Some(Method::Pause);
|
||||
let command = app.next_command_envelope();
|
||||
return Some(Method::Pause { command });
|
||||
}
|
||||
if let Some(t) = app.quit_confirm
|
||||
&& t.elapsed() < CONFIRM_TIMEOUT
|
||||
@@ -1476,8 +1488,8 @@ mod tests {
|
||||
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
Event, RewindTarget, RewindTargetId, RunResult, Segment, UploadedFileAvailability,
|
||||
UploadedFileRef, WorkerStatus,
|
||||
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerStatus,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1490,7 +1502,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() {
|
||||
let attach = Method::Run {
|
||||
let attach = Method::Submit {
|
||||
submission_request_id: protocol::new_submission_request_id(),
|
||||
input: vec![Segment::text("/attach /tmp/report.md")],
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -1499,7 +1512,8 @@ mod tests {
|
||||
);
|
||||
assert!(!is_clear_attachments_command(&attach));
|
||||
|
||||
let clear = Method::Run {
|
||||
let clear = Method::Submit {
|
||||
submission_request_id: protocol::new_submission_request_id(),
|
||||
input: vec![Segment::text("/clear-attachments")],
|
||||
};
|
||||
assert!(is_clear_attachments_command(&clear));
|
||||
@@ -1605,7 +1619,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_attachment_send_failure_restores_draft_without_exiting_console() {
|
||||
async fn running_attachment_submit_failure_restores_draft_without_exiting_console() {
|
||||
let file = UploadedFileRef {
|
||||
artifact_id: "artifact-queued".into(),
|
||||
file_name: "queued.txt".into(),
|
||||
@@ -1631,13 +1645,10 @@ mod tests {
|
||||
let mut app = App::new("worker".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
app.input.insert_str("queued inspect");
|
||||
assert!(app.submit_input().is_none());
|
||||
|
||||
let method = app
|
||||
.handle_worker_event(Event::RunEnd {
|
||||
result: RunResult::Finished,
|
||||
})
|
||||
.expect("queued run must be released");
|
||||
.submit_input()
|
||||
.expect("running Submit is sent immediately");
|
||||
|
||||
send_console_method(&mut app, &mut connection, &method)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1960,7 +1971,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_enter_queues_instead_of_sending_run() {
|
||||
fn running_enter_sends_submit_to_worker() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
for c in "queued".chars() {
|
||||
@@ -1973,102 +1984,128 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||
assert!(matches!(
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
Some(Method::Submit { .. })
|
||||
));
|
||||
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
assert_eq!(app.next_queued_input_preview(), Some("queued"));
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
assert_eq!(input_text(&app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_input_keybindings_restore_and_clear() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
fn running_alt_n_sends_explicit_notify_without_implicit_submit_conversion() {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
for c in "edit queued".chars() {
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
for character in "progress".chars() {
|
||||
app.insert_char(character);
|
||||
}
|
||||
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
|
||||
)
|
||||
.is_none()
|
||||
let method = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::ALT),
|
||||
);
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
assert_eq!(input_text(&app), "edit queued");
|
||||
|
||||
app.input.clear();
|
||||
for c in "clear queued".chars() {
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
assert!(matches!(
|
||||
method,
|
||||
Some(Method::Notify {
|
||||
ref message,
|
||||
auto_run: true,
|
||||
..
|
||||
}) if message == "progress"
|
||||
));
|
||||
assert_eq!(input_text(&app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_cancel_clear_queued_input() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
for c in "queued".chars() {
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
fn pending_queue_shortcuts_send_worker_operations() {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 2,
|
||||
notification_count: 0,
|
||||
head_id: Some("submission-1".into()),
|
||||
submissions: vec![protocol::PendingSubmissionSummary {
|
||||
submission_id: "submission-1".into(),
|
||||
accepted_at_ms: 1,
|
||||
segment_count: 1,
|
||||
byte_len: 6,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
let pause = handle_key(
|
||||
let continue_next = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT),
|
||||
);
|
||||
assert!(matches!(pause, Some(Method::Pause)));
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
|
||||
for c in "queued again".chars() {
|
||||
assert!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||
assert!(matches!(
|
||||
continue_next,
|
||||
Some(Method::ContinuePending {
|
||||
expected_revision: 2,
|
||||
ref expected_head_id,
|
||||
}) if expected_head_id == "submission-1"
|
||||
));
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
|
||||
let cancel = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT),
|
||||
);
|
||||
assert!(matches!(cancel, Some(Method::Cancel)));
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
assert!(matches!(
|
||||
cancel,
|
||||
Some(Method::CancelPendingSubmission {
|
||||
expected_revision: 2,
|
||||
ref submission_id,
|
||||
}) if submission_id == "submission-1"
|
||||
));
|
||||
|
||||
let clear = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT),
|
||||
);
|
||||
assert!(matches!(
|
||||
clear,
|
||||
Some(Method::ClearPendingSubmissions {
|
||||
expected_revision: 2
|
||||
})
|
||||
));
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_cancel_preserve_authoritative_pending_queue() {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 2,
|
||||
notification_count: 0,
|
||||
head_id: Some("submission-1".into()),
|
||||
submissions: vec![protocol::PendingSubmissionSummary {
|
||||
submission_id: "submission-1".into(),
|
||||
accepted_at_ms: 1,
|
||||
segment_count: 1,
|
||||
byte_len: 6,
|
||||
}],
|
||||
},
|
||||
});
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
assert!(matches!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
),
|
||||
Some(Method::Pause { .. })
|
||||
));
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
assert!(matches!(
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
),
|
||||
Some(Method::Cancel { .. })
|
||||
));
|
||||
assert_eq!(app.queued_input_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2080,7 +2117,7 @@ mod tests {
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(cancel, Some(Method::Cancel)));
|
||||
assert!(matches!(cancel, Some(Method::Cancel { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2102,7 +2139,7 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
handle_key(&mut app, ctrl_x()),
|
||||
Some(Method::Shutdown)
|
||||
Some(Method::Shutdown { .. })
|
||||
));
|
||||
assert!(app.shutdown_confirm.is_none());
|
||||
}
|
||||
@@ -2432,7 +2469,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(method, Some(protocol::Method::Compact)));
|
||||
assert!(matches!(method, Some(protocol::Method::Compact { .. })));
|
||||
assert!(!app.is_command_mode());
|
||||
assert_eq!(input_text(&app), "");
|
||||
assert_eq!(app.queued_input_count(), 0);
|
||||
@@ -2535,13 +2572,19 @@ mod tests {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
status: WorkerStatus::Idle,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![],
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![],
|
||||
},
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
@@ -2562,15 +2605,21 @@ mod tests {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
status: WorkerStatus::Idle,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![],
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
type_keys(&mut app, "draft");
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![],
|
||||
},
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
@@ -2697,8 +2746,8 @@ mod tests {
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::Status {
|
||||
status: WorkerStatus::Running,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
});
|
||||
enter_command_mode(&mut app);
|
||||
@@ -2813,8 +2862,8 @@ mod tests {
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::Status {
|
||||
status: WorkerStatus::Running,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2839,8 +2888,8 @@ mod tests {
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::Status {
|
||||
status: WorkerStatus::Running,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
});
|
||||
handle_key(&mut app, key(KeyCode::Tab));
|
||||
@@ -2856,7 +2905,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(first.is_none());
|
||||
assert!(matches!(second, Some(Method::Shutdown)));
|
||||
assert!(matches!(second, Some(Method::Shutdown { .. })));
|
||||
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
||||
}
|
||||
|
||||
@@ -2878,8 +2927,8 @@ mod tests {
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
event: Box::new(Event::Status {
|
||||
status: WorkerStatus::Running,
|
||||
event: Box::new(Event::WorkerState {
|
||||
snapshot: WorkerStatus::Running.into(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2918,12 +2967,12 @@ mod tests {
|
||||
type_keys(&mut app, "first");
|
||||
assert!(matches!(
|
||||
handle_key(&mut app, key(KeyCode::Enter)),
|
||||
Some(Method::Run { .. })
|
||||
Some(Method::Submit { .. })
|
||||
));
|
||||
type_keys(&mut app, "second");
|
||||
assert!(matches!(
|
||||
handle_key(&mut app, key(KeyCode::Enter)),
|
||||
Some(Method::Run { .. })
|
||||
Some(Method::Submit { .. })
|
||||
));
|
||||
|
||||
assert_eq!(input_text(&app), "");
|
||||
@@ -2954,7 +3003,7 @@ mod tests {
|
||||
type_keys(&mut app, "sent");
|
||||
assert!(matches!(
|
||||
handle_key(&mut app, key(KeyCode::Enter)),
|
||||
Some(Method::Run { .. })
|
||||
Some(Method::Submit { .. })
|
||||
));
|
||||
type_keys(&mut app, "draft\nbody");
|
||||
app.move_cursor_start();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app;
|
||||
mod backend_dashboard;
|
||||
mod backend_spawn;
|
||||
mod backend_worker_picker;
|
||||
mod backend_workspace_picker;
|
||||
mod block;
|
||||
@@ -51,6 +52,8 @@ pub enum LaunchMode {
|
||||
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
|
||||
/// `include_all` opts into all standalone Workers under the same client data root.
|
||||
StandaloneResume { include_all: bool },
|
||||
/// Create one Backend Worker and attach to it.
|
||||
BackendSpawn,
|
||||
/// List Backend Workers and attach to the selected Worker.
|
||||
Workers {
|
||||
runtime_id: Option<String>,
|
||||
@@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||
}
|
||||
}
|
||||
LaunchMode::BackendSpawn => match target.launch_backend_worker() {
|
||||
Ok(launch) => backend_spawn::run(launch.target).await,
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::Workers {
|
||||
runtime_id,
|
||||
include_stopped,
|
||||
|
||||
@@ -228,7 +228,7 @@ worker_context_max_tokens = 100000
|
||||
enabled = true
|
||||
|
||||
[feature.memory]
|
||||
enabled = true
|
||||
enabled = false
|
||||
|
||||
[feature.web]
|
||||
enabled = true
|
||||
@@ -241,11 +241,6 @@ enabled = true
|
||||
authoring = true
|
||||
thread = true
|
||||
|
||||
[memory]
|
||||
extract_threshold = 50000
|
||||
consolidation_threshold_files = 5
|
||||
consolidation_threshold_bytes = 50000
|
||||
|
||||
[web]
|
||||
enabled = true
|
||||
|
||||
|
||||
+22
-14
@@ -1880,7 +1880,7 @@ fn actionbar_left_item(app: &App, now: Instant) -> Option<(String, Style)> {
|
||||
}
|
||||
if app.queued_input_count() > 0 {
|
||||
return Some((
|
||||
"Alt-q edit queued Alt-c clear queued".to_string(),
|
||||
"Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".to_string(),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
@@ -2136,9 +2136,25 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||
use crate::block::{ToolCallBlock, ToolCallState};
|
||||
use protocol::WorkerStatus;
|
||||
use protocol::Event;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn set_pending_submission(app: &mut App, id: &str) {
|
||||
app.handle_worker_event(Event::PendingSubmissionsChanged {
|
||||
pending: protocol::PendingSubmissionsSnapshot {
|
||||
revision: 1,
|
||||
notification_count: 0,
|
||||
head_id: Some(id.into()),
|
||||
submissions: vec![protocol::PendingSubmissionSummary {
|
||||
submission_id: id.into(),
|
||||
accepted_at_ms: 1,
|
||||
segment_count: 1,
|
||||
byte_len: 1,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_status_line_matches_console_metrics_and_spinner_frame() {
|
||||
let now = Instant::now();
|
||||
@@ -2251,15 +2267,11 @@ mod tests {
|
||||
#[test]
|
||||
fn queue_status_text_includes_count_and_preview() {
|
||||
let mut app = App::new("test".into());
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
for c in "queued preview".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(app.submit_input().is_none());
|
||||
set_pending_submission(&mut app, "submission-1");
|
||||
|
||||
assert_eq!(
|
||||
queue_status_text(&app),
|
||||
Some("queued: 1 — queued preview".to_string())
|
||||
Some("queued: 1 — submission-1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2289,14 +2301,10 @@ mod tests {
|
||||
Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into())
|
||||
);
|
||||
|
||||
app.set_worker_status(WorkerStatus::Running);
|
||||
for c in "queued turn".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
assert!(app.submit_input().is_none());
|
||||
set_pending_submission(&mut app, "submission-1");
|
||||
assert_eq!(
|
||||
actionbar_left_item(&app, now).map(|(text, _)| text),
|
||||
Some("Alt-q edit queued Alt-c clear queued".into())
|
||||
Some("Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".into())
|
||||
);
|
||||
|
||||
app.enter_command_mode();
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+55
-43
@@ -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),
|
||||
@@ -68,12 +71,10 @@ pub enum WorkdirSessionOperation {
|
||||
CommandCancel(CommandHandle),
|
||||
}
|
||||
|
||||
/// Wire envelope for an operation and its optional provider-enforced child scope.
|
||||
/// Wire envelope for one provider operation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkdirSessionOperationRequest {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub delegations: Vec<crate::WorkdirDelegationRequest>,
|
||||
pub operation: WorkdirSessionOperation,
|
||||
}
|
||||
|
||||
@@ -81,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),
|
||||
@@ -289,13 +292,18 @@ mod client {
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
|
||||
use super::*;
|
||||
use crate::{Workdir, WorkdirSession, WorkdirSessionHandle};
|
||||
use crate::{Workdir, WorkdirSession};
|
||||
|
||||
/// Provides a fresh bearer token for each Runtime request. Backend
|
||||
/// 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>);
|
||||
@@ -307,7 +315,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())
|
||||
}
|
||||
}
|
||||
@@ -324,7 +337,6 @@ mod client {
|
||||
workdir: Workdir,
|
||||
session_id: WorkdirSessionId,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
delegations: Vec<crate::WorkdirDelegationRequest>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
@@ -357,10 +369,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)?;
|
||||
@@ -377,7 +393,6 @@ mod client {
|
||||
workdir: Workdir::new(opened.workdir_id.as_str()),
|
||||
session_id: opened.session_id,
|
||||
capabilities: opened.capabilities,
|
||||
delegations: Vec::new(),
|
||||
closed: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
@@ -404,15 +419,16 @@ mod client {
|
||||
"operations",
|
||||
],
|
||||
)?;
|
||||
let operation = WorkdirSessionOperationRequest {
|
||||
delegations: self.delegations.clone(),
|
||||
operation,
|
||||
};
|
||||
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)?;
|
||||
@@ -436,35 +452,30 @@ mod client {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
fn transports_delegation_context(&self) -> bool {
|
||||
true
|
||||
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 capture_delegation_source(
|
||||
async fn scope_rules_overlap(
|
||||
&self,
|
||||
request: &crate::WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(WorkdirError::SessionClosed);
|
||||
request: WorkdirScopeOverlapRequest,
|
||||
) -> Result<bool, WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
|
||||
_ => Err(Self::mismatch("scope_rules_overlap")),
|
||||
}
|
||||
let mut delegations = self.delegations.clone();
|
||||
delegations.push(request.clone());
|
||||
let candidate = Arc::new(Self {
|
||||
client: self.client.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
authorization: self.authorization.clone(),
|
||||
workdir: self.workdir.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
capabilities: self.capabilities,
|
||||
delegations,
|
||||
closed: AtomicBool::new(false),
|
||||
});
|
||||
candidate
|
||||
.stat(StatRequest {
|
||||
path: fs_operation::FsPath::new("").expect("empty Workdir path is valid"),
|
||||
})
|
||||
.await?;
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
@@ -581,10 +592,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)?;
|
||||
|
||||
+26
-31
@@ -5,10 +5,10 @@
|
||||
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
||||
//! materialization or cleanup.
|
||||
|
||||
mod delegation;
|
||||
pub mod http;
|
||||
mod local;
|
||||
mod operation;
|
||||
mod scope;
|
||||
pub mod workspace;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -18,11 +18,6 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub use delegation::{
|
||||
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
|
||||
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
|
||||
apply_delegation_chain, delegation_capable_session,
|
||||
};
|
||||
pub use fs_operation::{
|
||||
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
|
||||
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
||||
@@ -32,6 +27,11 @@ pub use local::{
|
||||
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
||||
};
|
||||
pub use operation::*;
|
||||
pub use scope::{
|
||||
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
|
||||
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
|
||||
WorkdirToolScopeRule,
|
||||
};
|
||||
|
||||
/// Persistent, opaque identity of one materialized Workdir.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -148,36 +148,31 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
fn workdir(&self) -> &Workdir;
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
||||
|
||||
fn is_delegation_capable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this session transports the delegation chain to another
|
||||
/// provider boundary that will apply logical cwd/path resolution there.
|
||||
fn transports_delegation_context(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Capture a provider-specific source for a delegated child session.
|
||||
/// Remote providers use this boundary to pin attachment identity without
|
||||
/// exposing transport handles or host paths.
|
||||
async fn capture_delegation_source(
|
||||
/// 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: &WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
Err(WorkdirError::Denied(
|
||||
"workdir provider does not support delegated sessions".into(),
|
||||
))
|
||||
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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Attenuate this session into a revocable child lease. Only sessions
|
||||
/// created with [`delegation_capable_session`] implement this operation.
|
||||
async fn delegate(
|
||||
async fn scope_rules_overlap(
|
||||
&self,
|
||||
_request: WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirDelegation, WorkdirError> {
|
||||
_request: WorkdirScopeOverlapRequest,
|
||||
) -> Result<bool, WorkdirError> {
|
||||
Err(WorkdirError::Denied(
|
||||
"workdir session is not delegation-capable".into(),
|
||||
"Workdir provider cannot compare resolved scope authority".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+358
-87
@@ -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::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
|
||||
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, broadcast, watch};
|
||||
@@ -28,9 +28,9 @@ use crate::{
|
||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
||||
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
|
||||
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
||||
WriteResult,
|
||||
};
|
||||
#[cfg(test)]
|
||||
@@ -213,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)]
|
||||
@@ -399,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(
|
||||
@@ -408,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),
|
||||
@@ -558,67 +604,84 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
self.inner.capabilities
|
||||
}
|
||||
|
||||
async fn capture_delegation_source(
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: &WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
let host_rules = request
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| ScopeRule {
|
||||
target: self.inner.root.join(rule.target.as_str()),
|
||||
permission: match rule.permission {
|
||||
WorkdirDelegationPermission::Read => Permission::Read,
|
||||
WorkdirDelegationPermission::Write => Permission::Write,
|
||||
},
|
||||
recursive: rule.recursive,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (logical, host) in request.rules.iter().zip(&host_rules) {
|
||||
if logical.permission == WorkdirDelegationPermission::Write {
|
||||
let resolved = Scope::resolved_target(host)
|
||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
|
||||
if resolved != host.target {
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"write delegation target `{}` traverses a symlink",
|
||||
logical.target
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let parent_scope = self.inner.scope.snapshot();
|
||||
for rule in &host_rules {
|
||||
if !parent_scope
|
||||
.allows_rule(rule)
|
||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"delegated provider scope `{}` exceeds the parent session",
|
||||
rule.target.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let child_scope = Scope::from_config(&ScopeConfig {
|
||||
allow: host_rules,
|
||||
deny: Vec::new(),
|
||||
})
|
||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
|
||||
let child_cwd = self.inner.root.join(request.cwd.as_str());
|
||||
if !child_scope.is_readable(&child_cwd)
|
||||
|| !std::fs::metadata(&child_cwd).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
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!(
|
||||
"delegated cwd `{}` is not a readable Workdir directory",
|
||||
request.cwd
|
||||
"Workdir path `{}` exceeds the provider attachment scope",
|
||||
request.path
|
||||
)));
|
||||
}
|
||||
Ok(Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
self.inner.workdir.clone(),
|
||||
self.inner.root.clone(),
|
||||
self.inner.root.clone(),
|
||||
SharedScope::new(child_scope),
|
||||
self.inner.capabilities,
|
||||
)))
|
||||
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> {
|
||||
@@ -694,9 +757,20 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
{
|
||||
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
|
||||
}
|
||||
let cwd = if let Some(logical_cwd) = request.cwd.as_ref() {
|
||||
let cwd = self.resolve(logical_cwd);
|
||||
let scope = self.inner.scope.snapshot();
|
||||
if !scope.is_readable(&cwd)
|
||||
|| !std::fs::metadata(&cwd).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
return Err(WorkdirError::OutOfScope(cwd));
|
||||
}
|
||||
cwd
|
||||
} else {
|
||||
self.inner.cwd.clone()
|
||||
};
|
||||
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
||||
let handle = CommandHandle(format!("command-{id}"));
|
||||
let cwd = self.inner.cwd.clone();
|
||||
let (completion_tx, completion) = watch::channel(false);
|
||||
let command_id = handle.0.clone();
|
||||
let telemetry = self.inner.command_telemetry.clone();
|
||||
@@ -1388,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();
|
||||
@@ -1516,6 +1606,7 @@ mod tests {
|
||||
command: "sleep 30".to_owned(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
@@ -1586,6 +1677,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();
|
||||
@@ -1688,7 +1875,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();
|
||||
@@ -1698,16 +1885,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)]
|
||||
@@ -1799,7 +1978,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();
|
||||
@@ -1809,15 +1988,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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1840,11 +2018,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();
|
||||
@@ -1908,6 +2088,7 @@ mod tests {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1944,6 +2125,7 @@ mod tests {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1980,6 +2162,7 @@ mod tests {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
@@ -1995,6 +2178,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();
|
||||
@@ -2043,6 +2303,7 @@ mod tests {
|
||||
command: "pwd && printf provider-command".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 4096,
|
||||
cwd: None,
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
@@ -2081,11 +2342,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(),
|
||||
@@ -2141,6 +2404,7 @@ mod tests {
|
||||
command: "printf hidden".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1,
|
||||
cwd: None,
|
||||
spill_dir: Some(spill.path().to_path_buf()),
|
||||
tool_call_id: None,
|
||||
},
|
||||
@@ -2161,11 +2425,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(),
|
||||
@@ -2178,6 +2444,7 @@ mod tests {
|
||||
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 64,
|
||||
cwd: None,
|
||||
spill_dir: Some(spill.path().to_path_buf()),
|
||||
tool_call_id: None,
|
||||
},
|
||||
@@ -2224,6 +2491,7 @@ mod tests {
|
||||
command: "printf 'aéz'".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
@@ -2449,6 +2717,7 @@ mod tests {
|
||||
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: None,
|
||||
tool_call_id: Some("tool-7".into()),
|
||||
},
|
||||
@@ -2553,6 +2822,7 @@ mod tests {
|
||||
command: "sleep 30".into(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
@@ -2623,6 +2893,7 @@ mod tests {
|
||||
command: "sleep 30".into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,10 @@ pub struct CommandRequest {
|
||||
pub command: String,
|
||||
pub timeout_secs: u64,
|
||||
pub output_limit: usize,
|
||||
/// Workdir-relative command directory. Providers validate it against the
|
||||
/// active session before process start.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<fs_operation::FsPath>,
|
||||
/// Provider-local directory where complete output is retained when the
|
||||
/// inline result exceeds `output_limit`.
|
||||
pub spill_dir: Option<PathBuf>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,15 +104,5 @@ mod tests {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceWorkdirSessionOperationRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_session_fence: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub delegations: Vec<crate::WorkdirDelegationRequest>,
|
||||
pub operation: crate::http::WorkdirSessionOperation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceWorkdirSessionFence {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ 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
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,32 +15,22 @@ pub enum ProfileSelector {
|
||||
Named(String),
|
||||
}
|
||||
|
||||
/// Runtime fetch/caching metadata for a Backend-authored Decodal profile source archive.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileSourceArchiveHttpRef {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub etag: Option<String>,
|
||||
pub archive: ProfileSourceArchiveRef,
|
||||
}
|
||||
|
||||
/// Profile source material available to a Runtime during Worker creation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProfileSourceArchiveSource {
|
||||
/// Backend-internal embedded runtimes may receive already-built archive bytes.
|
||||
Embedded { archive: ProfileSourceArchive },
|
||||
/// Standalone runtimes fetch/cache the tar archive over HTTP.
|
||||
Http {
|
||||
location: ProfileSourceArchiveHttpRef,
|
||||
},
|
||||
/// Standalone runtimes resolve this immutable archive from the latest
|
||||
/// Workspace Config bundle before creating the Worker.
|
||||
WorkspaceConfig { archive: ProfileSourceArchiveRef },
|
||||
}
|
||||
|
||||
impl ProfileSourceArchiveSource {
|
||||
pub fn reference(&self) -> ProfileSourceArchiveRef {
|
||||
match self {
|
||||
Self::Embedded { archive } => archive.reference.clone(),
|
||||
Self::Http { location } => location.archive.clone(),
|
||||
Self::WorkspaceConfig { archive } => archive.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,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,
|
||||
@@ -141,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,
|
||||
}
|
||||
|
||||
@@ -153,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>,
|
||||
}
|
||||
@@ -307,6 +300,8 @@ pub struct WorkerSummary {
|
||||
pub worker_id: WorkerId,
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub working_directory: Option<WorkingDirectoryStatus>,
|
||||
@@ -325,6 +320,8 @@ pub struct WorkerDetail {
|
||||
pub worker_id: WorkerId,
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub working_directory: Option<WorkingDirectoryStatus>,
|
||||
@@ -341,6 +338,8 @@ pub struct WorkerDetail {
|
||||
pub struct WorkerLifecycleAck {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,6 +9,11 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256";
|
||||
pub const WORKSPACE_CONFIG_ETAG_PREFIX: &str = "workspace-config:";
|
||||
|
||||
pub fn workspace_config_etag(digest: &str) -> String {
|
||||
format!("\"{WORKSPACE_CONFIG_ETAG_PREFIX}{digest}\"")
|
||||
}
|
||||
|
||||
/// Backend-synced Profile/config bundle stored by a Runtime.
|
||||
///
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::catalog::{
|
||||
RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||
ConfigBundleRef, ProfileSelector, RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
WorkspaceApiRef,
|
||||
};
|
||||
use crate::config_bundle::ConfigBundle;
|
||||
use crate::error::RuntimeError;
|
||||
@@ -15,18 +16,6 @@ use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use workdir::WorkdirSessionHandle;
|
||||
|
||||
/// Current execution-side run state for a Worker.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerExecutionRunState {
|
||||
#[default]
|
||||
Stopped,
|
||||
Idle,
|
||||
Busy,
|
||||
Rejected,
|
||||
Errored,
|
||||
}
|
||||
|
||||
/// Execution operation that produced a result.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -41,14 +30,12 @@ pub enum WorkerExecutionOperation {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Evidence that a user input reached the durable Worker session boundary.
|
||||
///
|
||||
/// This is intentionally distinct from accepting a method on the Worker's
|
||||
/// in-memory channel. For Flow submissions, the committed UserInput entry also
|
||||
/// carries the initial Flow runtime-state extension.
|
||||
/// Evidence that a Submit request reached the durable Worker session boundary.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerInputCommitAck {
|
||||
pub struct WorkerSubmissionAck {
|
||||
pub submission_request_id: String,
|
||||
pub submission_id: String,
|
||||
pub disposition: protocol::SubmissionDisposition,
|
||||
}
|
||||
|
||||
/// Typed execution result class. Results are transient operation outcomes and
|
||||
@@ -57,11 +44,12 @@ pub struct WorkerInputCommitAck {
|
||||
pub struct WorkerExecutionResult {
|
||||
pub operation: WorkerExecutionOperation,
|
||||
pub outcome: WorkerExecutionOutcome,
|
||||
pub run_state: WorkerExecutionRunState,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_commit: Option<WorkerInputCommitAck>,
|
||||
pub submission: Option<WorkerSubmissionAck>,
|
||||
}
|
||||
|
||||
/// Backend result class for a Worker execution operation.
|
||||
@@ -76,31 +64,36 @@ pub enum WorkerExecutionOutcome {
|
||||
}
|
||||
|
||||
impl WorkerExecutionResult {
|
||||
pub fn accepted(
|
||||
operation: WorkerExecutionOperation,
|
||||
run_state: WorkerExecutionRunState,
|
||||
) -> Self {
|
||||
pub fn accepted(operation: WorkerExecutionOperation) -> Self {
|
||||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Accepted,
|
||||
run_state,
|
||||
worker_state: None,
|
||||
message: None,
|
||||
input_commit: None,
|
||||
submission: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accepted_input_committed(
|
||||
pub fn with_worker_state(mut self, worker_state: protocol::WorkerStateSnapshot) -> Self {
|
||||
self.worker_state = Some(worker_state);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn accepted_submission(
|
||||
operation: WorkerExecutionOperation,
|
||||
run_state: WorkerExecutionRunState,
|
||||
submission_request_id: impl Into<String>,
|
||||
submission_id: impl Into<String>,
|
||||
disposition: protocol::SubmissionDisposition,
|
||||
) -> Self {
|
||||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Accepted,
|
||||
run_state,
|
||||
worker_state: None,
|
||||
message: None,
|
||||
input_commit: Some(WorkerInputCommitAck {
|
||||
submission: Some(WorkerSubmissionAck {
|
||||
submission_request_id: submission_request_id.into(),
|
||||
submission_id: submission_id.into(),
|
||||
disposition,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -109,9 +102,9 @@ impl WorkerExecutionResult {
|
||||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Busy,
|
||||
run_state: WorkerExecutionRunState::Busy,
|
||||
worker_state: None,
|
||||
message: Some(message.into()),
|
||||
input_commit: None,
|
||||
submission: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +112,9 @@ impl WorkerExecutionResult {
|
||||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Rejected,
|
||||
run_state: WorkerExecutionRunState::Stopped,
|
||||
worker_state: None,
|
||||
message: Some(message.into()),
|
||||
input_commit: None,
|
||||
submission: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +122,9 @@ impl WorkerExecutionResult {
|
||||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Errored,
|
||||
run_state: WorkerExecutionRunState::Errored,
|
||||
worker_state: None,
|
||||
message: Some(message.into()),
|
||||
input_commit: None,
|
||||
submission: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +132,9 @@ impl WorkerExecutionResult {
|
||||
Self {
|
||||
operation,
|
||||
outcome: WorkerExecutionOutcome::Unsupported,
|
||||
run_state: WorkerExecutionRunState::Stopped,
|
||||
worker_state: None,
|
||||
message: Some(message.into()),
|
||||
input_commit: None,
|
||||
submission: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,12 +266,28 @@ pub struct WorkerExecutionRestoreRequest {
|
||||
pub config_bundle: Option<ConfigBundle>,
|
||||
}
|
||||
|
||||
/// Runtime-side request to refresh the latest Workspace Config before Worker creation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceConfigFetchRequest {
|
||||
pub workspace_api: WorkspaceApiRef,
|
||||
pub profile: ProfileSelector,
|
||||
pub expected: ConfigBundleRef,
|
||||
pub cached: Option<ConfigBundleRef>,
|
||||
}
|
||||
|
||||
/// Result of a conditional Workspace Config fetch.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WorkspaceConfigFetchResult {
|
||||
NotModified,
|
||||
Modified(ConfigBundle),
|
||||
}
|
||||
|
||||
/// Backend outcome for Worker spawn/restore operations.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WorkerExecutionSpawnResult {
|
||||
Connected {
|
||||
handle: WorkerExecutionHandle,
|
||||
run_state: WorkerExecutionRunState,
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
working_directory: Option<WorkingDirectoryStatus>,
|
||||
},
|
||||
Rejected(WorkerExecutionResult),
|
||||
@@ -288,12 +297,12 @@ pub enum WorkerExecutionSpawnResult {
|
||||
impl WorkerExecutionSpawnResult {
|
||||
pub fn connected(
|
||||
handle: WorkerExecutionHandle,
|
||||
run_state: WorkerExecutionRunState,
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
working_directory: Option<WorkingDirectoryStatus>,
|
||||
) -> Self {
|
||||
Self::Connected {
|
||||
handle,
|
||||
run_state,
|
||||
worker_state,
|
||||
working_directory,
|
||||
}
|
||||
}
|
||||
@@ -302,6 +311,13 @@ impl WorkerExecutionSpawnResult {
|
||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
fn backend_id(&self) -> &str;
|
||||
|
||||
fn fetch_workspace_config(
|
||||
&self,
|
||||
_request: WorkspaceConfigFetchRequest,
|
||||
) -> Result<WorkspaceConfigFetchResult, String> {
|
||||
Err("execution backend does not support Workspace Config fetching".to_string())
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
||||
|
||||
fn restore_worker(
|
||||
@@ -483,6 +499,13 @@ impl WorkerExecutionBackendRef {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_workspace_config(
|
||||
&self,
|
||||
request: WorkspaceConfigFetchRequest,
|
||||
) -> Result<WorkspaceConfigFetchResult, String> {
|
||||
self.backend.fetch_workspace_config(request)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_worker(
|
||||
&self,
|
||||
request: WorkerExecutionSpawnRequest,
|
||||
@@ -618,14 +641,16 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn input_commit_ack_survives_json_round_trip() {
|
||||
let result = WorkerExecutionResult::accepted_input_committed(
|
||||
fn submission_ack_survives_json_round_trip() {
|
||||
let result = WorkerExecutionResult::accepted_submission(
|
||||
WorkerExecutionOperation::Input,
|
||||
WorkerExecutionRunState::Busy,
|
||||
"request-1",
|
||||
"submission-1",
|
||||
protocol::SubmissionDisposition::Started,
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"submission_request_id\":\"request-1\""));
|
||||
assert!(json.contains("\"submission_id\":\"submission-1\""));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(),
|
||||
|
||||
@@ -15,7 +15,10 @@ use std::io::{BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const SCHEMA_VERSION: u32 = 4;
|
||||
const SCHEMA_VERSION: u32 = 6;
|
||||
const PREVIOUS_SCHEMA_VERSION: u32 = 5;
|
||||
const EXECUTION_SCHEMA_VERSION: u32 = 4;
|
||||
const PRE_EXECUTION_SCHEMA_VERSION: u32 = 3;
|
||||
const RUNTIME_FILE: &str = "runtime.json";
|
||||
const WORKERS_DIR: &str = "workers";
|
||||
const WORKER_FILE: &str = "worker.json";
|
||||
@@ -283,6 +286,7 @@ pub(crate) struct PersistedWorkerExecutionBinding {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct PersistedWorkerExecution {
|
||||
pub(crate) last_run_generation: u64,
|
||||
pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
|
||||
pub(crate) restore_intent: WorkerRestoreIntent,
|
||||
}
|
||||
@@ -370,8 +374,8 @@ fn plan_runtime_store_migration(
|
||||
format!("Runtime store schema version {schema_version} is out of range"),
|
||||
)
|
||||
})?;
|
||||
let staging = migration_sibling(root, "schema-v4-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
|
||||
let staging = migration_sibling(root, "schema-v6-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v6-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
@@ -397,11 +401,14 @@ fn plan_runtime_store_migration(
|
||||
};
|
||||
return Ok((plan, Vec::new()));
|
||||
}
|
||||
if current_schema_version != 3 {
|
||||
if !matches!(
|
||||
current_schema_version,
|
||||
PRE_EXECUTION_SCHEMA_VERSION | EXECUTION_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION
|
||||
) {
|
||||
return Err(runtime_store_corrupt(
|
||||
&runtime_path,
|
||||
format!(
|
||||
"unsupported Runtime store schema version {schema_version}; expected 3 or {SCHEMA_VERSION}"
|
||||
"unsupported Runtime store schema version {schema_version}; expected {PRE_EXECUTION_SCHEMA_VERSION}, {EXECUTION_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -428,6 +435,16 @@ fn plan_runtime_store_migration(
|
||||
runtime_store_corrupt(&source_dir, "Worker directory is not UTF-8".to_string())
|
||||
})?;
|
||||
let snapshot_path = source_dir.join(WORKER_FILE);
|
||||
if !snapshot_path
|
||||
.try_exists()
|
||||
.map_err(|source| RuntimeError::StoreIo {
|
||||
operation: "inspect Worker snapshot",
|
||||
path: snapshot_path.clone(),
|
||||
source,
|
||||
})?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
|
||||
let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 {
|
||||
let legacy_worker_id = name.parse::<u64>().map_err(|_| {
|
||||
@@ -616,6 +633,38 @@ fn migrate_v1_worker_document(
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn max_persisted_run_generation(snapshot_path: &Path) -> Result<u64, RuntimeError> {
|
||||
let worker_dir = snapshot_path.parent().ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
snapshot_path,
|
||||
"Worker snapshot path is missing its aggregate directory".to_string(),
|
||||
)
|
||||
})?;
|
||||
let runs_dir = worker_dir.join("runs");
|
||||
if !runs_dir
|
||||
.try_exists()
|
||||
.map_err(|source| runtime_io_error("inspect Worker runs", &runs_dir, source))?
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
let entries = fs::read_dir(&runs_dir)
|
||||
.map_err(|source| runtime_io_error("read Worker runs", &runs_dir, source))?;
|
||||
let mut max_generation = 0;
|
||||
for entry in entries {
|
||||
let entry =
|
||||
entry.map_err(|source| runtime_io_error("read Worker runs", &runs_dir, source))?;
|
||||
let Some(generation) = entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.and_then(|name| name.parse::<u64>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
max_generation = max_generation.max(generation);
|
||||
}
|
||||
Ok(max_generation)
|
||||
}
|
||||
|
||||
fn migrate_worker_document(
|
||||
mut document: serde_json::Value,
|
||||
source_schema_version: u32,
|
||||
@@ -640,7 +689,7 @@ fn migrate_worker_document(
|
||||
"Worker snapshot must be an object".to_string(),
|
||||
)
|
||||
})?;
|
||||
let run_generation = object
|
||||
let declared_run_generation = object
|
||||
.remove("run_generation")
|
||||
.map(|value| {
|
||||
value.as_u64().ok_or_else(|| {
|
||||
@@ -650,9 +699,45 @@ fn migrate_worker_document(
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.filter(|generation| *generation > 0);
|
||||
.transpose()?;
|
||||
let legacy_execution = object.remove("execution");
|
||||
let execution = legacy_execution
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let persisted_last_run_generation = execution
|
||||
.and_then(|execution| execution.get("last_run_generation"))
|
||||
.map(|value| {
|
||||
value.as_u64().ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
snapshot_path,
|
||||
"Worker execution last_run_generation must be an unsigned integer".to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let binding_run_generation = execution
|
||||
.and_then(|execution| execution.get("binding"))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|binding| binding.get("run_generation"))
|
||||
.map(|value| {
|
||||
value.as_u64().ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
snapshot_path,
|
||||
"Worker execution binding run_generation must be an unsigned integer"
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let run_generation = declared_run_generation
|
||||
.into_iter()
|
||||
.chain(persisted_last_run_generation)
|
||||
.chain(binding_run_generation)
|
||||
.chain(std::iter::once(max_persisted_run_generation(
|
||||
snapshot_path,
|
||||
)?))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if !object.contains_key("working_directory") {
|
||||
if let Some(working_directory) = legacy_execution
|
||||
.as_ref()
|
||||
@@ -663,6 +748,42 @@ fn migrate_worker_document(
|
||||
object.insert("working_directory".to_string(), working_directory);
|
||||
}
|
||||
}
|
||||
let legacy_materialization = object
|
||||
.get("working_directory")
|
||||
.and_then(|working_directory| working_directory.get("summary"))
|
||||
.and_then(|summary| summary.get("materializer_kind"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "runtime_git_cache" | "local_git_worktree"));
|
||||
if legacy_materialization {
|
||||
object.insert("working_directory".to_string(), serde_json::Value::Null);
|
||||
}
|
||||
if let Some(profile_source) = object
|
||||
.get_mut("request")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.and_then(|request| request.get_mut("profile_source"))
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
&& profile_source
|
||||
.get("kind")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("http")
|
||||
{
|
||||
let archive = profile_source
|
||||
.get_mut("location")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.and_then(|location| location.remove("archive"))
|
||||
.ok_or_else(|| {
|
||||
runtime_store_corrupt(
|
||||
snapshot_path,
|
||||
"legacy HTTP profile source is missing its archive".to_string(),
|
||||
)
|
||||
})?;
|
||||
profile_source.clear();
|
||||
profile_source.insert(
|
||||
"kind".to_string(),
|
||||
serde_json::Value::String("workspace_config".to_string()),
|
||||
);
|
||||
profile_source.insert("archive".to_string(), archive);
|
||||
}
|
||||
object.insert(
|
||||
"schema_version".to_string(),
|
||||
serde_json::Value::from(SCHEMA_VERSION),
|
||||
@@ -674,9 +795,8 @@ fn migrate_worker_document(
|
||||
object.insert(
|
||||
"execution".to_string(),
|
||||
serde_json::json!({
|
||||
"binding": run_generation.map(|run_generation| {
|
||||
serde_json::json!({ "run_generation": run_generation })
|
||||
}),
|
||||
"last_run_generation": run_generation,
|
||||
"binding": null,
|
||||
"restore_intent": "explicit",
|
||||
}),
|
||||
);
|
||||
@@ -759,8 +879,8 @@ fn migrate_worker_aggregate_document(
|
||||
.get_mut("resolved_manifest_snapshot")
|
||||
.filter(|snapshot| !snapshot.is_null())
|
||||
{
|
||||
let manifest: manifest::WorkerManifest =
|
||||
serde_json::from_value(snapshot.clone()).map_err(|error| {
|
||||
let mut manifest = manifest::read_persisted_worker_manifest_snapshot(snapshot.clone())
|
||||
.map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
metadata_path,
|
||||
format!("decode Worker aggregate resolved manifest snapshot: {error}"),
|
||||
@@ -775,20 +895,14 @@ fn migrate_worker_aggregate_document(
|
||||
),
|
||||
));
|
||||
}
|
||||
snapshot
|
||||
.as_object_mut()
|
||||
.and_then(|manifest| manifest.get_mut("worker"))
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.ok_or_else(|| {
|
||||
manifest.worker.name = expected_name.clone();
|
||||
*snapshot =
|
||||
manifest::write_persisted_worker_manifest_snapshot(&manifest).map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
metadata_path,
|
||||
"Worker aggregate resolved manifest is missing worker metadata".to_string(),
|
||||
format!("encode migrated Worker aggregate resolved manifest: {error}"),
|
||||
)
|
||||
})?
|
||||
.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(expected_name.clone()),
|
||||
);
|
||||
})?;
|
||||
}
|
||||
metadata.insert(
|
||||
"worker_name".to_string(),
|
||||
@@ -809,8 +923,8 @@ fn migrate_worker_aggregate_document(
|
||||
));
|
||||
}
|
||||
if let Some(snapshot) = metadata.resolved_manifest_snapshot {
|
||||
let manifest: manifest::WorkerManifest =
|
||||
serde_json::from_value(snapshot).map_err(|error| {
|
||||
let manifest =
|
||||
manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|error| {
|
||||
runtime_store_corrupt(
|
||||
metadata_path,
|
||||
format!("decode migrated Worker aggregate resolved manifest: {error}"),
|
||||
@@ -1054,8 +1168,8 @@ fn migrate_runtime_store(
|
||||
if !plan.migration_required {
|
||||
return Ok(plan);
|
||||
}
|
||||
let staging = migration_sibling(root, "schema-v4-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
|
||||
let staging = migration_sibling(root, "schema-v6-staging")?;
|
||||
let backup = migration_sibling(root, "pre-schema-v6-backup")?;
|
||||
if staging.exists() || backup.exists() {
|
||||
return Err(runtime_store_corrupt(
|
||||
root,
|
||||
@@ -1328,6 +1442,18 @@ impl WorkerSnapshot {
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(binding) = self.execution.binding.as_ref()
|
||||
&& binding.run_generation != self.execution.last_run_generation
|
||||
{
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "read worker snapshot",
|
||||
path: path.to_path_buf(),
|
||||
message: format!(
|
||||
"execution binding run_generation {} does not match last_run_generation {}",
|
||||
binding.run_generation, self.execution.last_run_generation
|
||||
),
|
||||
});
|
||||
}
|
||||
match (self.status, self.execution.restore_intent) {
|
||||
(status, WorkerRestoreIntent::Automatic) if status.is_active() => {
|
||||
let Some(binding) = self.execution.binding.as_ref() else {
|
||||
@@ -1497,3 +1623,138 @@ fn sync_directory(path: &Path, operation: &'static str) -> Result<(), RuntimeErr
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_v4_migration_plan_ignores_orphan_worker_directories() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
root.path().join(RUNTIME_FILE),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"schema_version": PREVIOUS_SCHEMA_VERSION,
|
||||
"display_name": null,
|
||||
"backend": "fs_store",
|
||||
"status": "running",
|
||||
"next_diagnostic_id": 1,
|
||||
"config_bundles": {},
|
||||
"workspace_owners": {},
|
||||
"diagnostics": []
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root.path().join(WORKERS_DIR).join("orphan").join("session")).unwrap();
|
||||
fs::write(
|
||||
root.path()
|
||||
.join(WORKERS_DIR)
|
||||
.join("orphan")
|
||||
.join("session")
|
||||
.join("history.json"),
|
||||
b"[]",
|
||||
)
|
||||
.unwrap();
|
||||
let (plan, _) = plan_runtime_store_migration(root.path(), "runtime-test").unwrap();
|
||||
|
||||
assert!(plan.migration_required);
|
||||
assert_eq!(plan.current_schema_version, PREVIOUS_SCHEMA_VERSION);
|
||||
assert_eq!(plan.target_schema_version, SCHEMA_VERSION);
|
||||
assert_eq!(plan.worker_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v5_worker_migration_recovers_last_generation_from_run_aggregates() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let worker_dir = root.path().join("worker-a");
|
||||
fs::create_dir_all(worker_dir.join("runs/1")).unwrap();
|
||||
fs::create_dir_all(worker_dir.join("runs/7")).unwrap();
|
||||
fs::create_dir_all(worker_dir.join("runs/incomplete")).unwrap();
|
||||
let path = worker_dir.join(WORKER_FILE);
|
||||
let source = serde_json::json!({
|
||||
"schema_version": 5,
|
||||
"execution": {
|
||||
"binding": null,
|
||||
"restore_intent": "explicit"
|
||||
}
|
||||
});
|
||||
|
||||
let migrated =
|
||||
migrate_worker_document(source, PREVIOUS_SCHEMA_VERSION, None, &path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
migrated["execution"]["last_run_generation"],
|
||||
serde_json::json!(7)
|
||||
);
|
||||
assert_eq!(migrated["execution"]["binding"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v4_worker_migration_discards_unsupported_linked_worktree_binding() {
|
||||
let source = serde_json::json!({
|
||||
"schema_version": 4,
|
||||
"request": {
|
||||
"profile_source": {
|
||||
"kind": "http",
|
||||
"location": {
|
||||
"url": "https://workspace.example.test/archive",
|
||||
"etag": "profile-source:test",
|
||||
"archive": {
|
||||
"id": "profiles-v1",
|
||||
"digest": "sha256:test",
|
||||
"size_bytes": 1,
|
||||
"source_graph": {
|
||||
"source_count": 1,
|
||||
"total_source_bytes": 1,
|
||||
"entrypoints": {},
|
||||
"import_count": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"working_directory": {
|
||||
"summary": {
|
||||
"materializer_kind": "runtime_git_cache"
|
||||
}
|
||||
}
|
||||
});
|
||||
let path = Path::new("worker.json");
|
||||
|
||||
let migrated =
|
||||
migrate_worker_document(source, EXECUTION_SCHEMA_VERSION, None, path).unwrap();
|
||||
|
||||
assert_eq!(migrated["schema_version"], SCHEMA_VERSION);
|
||||
assert_eq!(migrated["status"], "stopped");
|
||||
assert_eq!(migrated["working_directory"], serde_json::Value::Null);
|
||||
assert_eq!(
|
||||
migrated["request"]["profile_source"]["kind"],
|
||||
"workspace_config"
|
||||
);
|
||||
assert_eq!(
|
||||
migrated["request"]["profile_source"]["archive"]["id"],
|
||||
"profiles-v1"
|
||||
);
|
||||
assert_eq!(migrated["execution"]["restore_intent"], "explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v4_worker_migration_preserves_runtime_clone_observation() {
|
||||
let source = serde_json::json!({
|
||||
"schema_version": 4,
|
||||
"working_directory": {
|
||||
"summary": {
|
||||
"materializer_kind": "runtime_git_clone"
|
||||
}
|
||||
}
|
||||
});
|
||||
let expected = source["working_directory"].clone();
|
||||
let path = Path::new("worker.json");
|
||||
|
||||
let migrated =
|
||||
migrate_worker_document(source, EXECUTION_SCHEMA_VERSION, None, path).unwrap();
|
||||
|
||||
assert_eq!(migrated["working_directory"], expected);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,10 +25,10 @@ impl WorkerInputKind {
|
||||
pub struct WorkerInput {
|
||||
pub kind: WorkerInputKind,
|
||||
pub content: String,
|
||||
/// Runtime-generated correlation id. This is never accepted from public
|
||||
/// JSON input and is consumed only by the execution backend.
|
||||
#[serde(skip)]
|
||||
pub submission_id: Option<String>,
|
||||
/// Authenticated client-generated idempotency key. Runtime generates one
|
||||
/// only for trusted internal callers that omit it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submission_request_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub segments: Option<Vec<Segment>>,
|
||||
}
|
||||
@@ -38,7 +38,7 @@ impl WorkerInput {
|
||||
Self {
|
||||
kind: WorkerInputKind::User,
|
||||
content: content.into(),
|
||||
submission_id: None,
|
||||
submission_request_id: None,
|
||||
segments: None,
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ impl WorkerInput {
|
||||
Self {
|
||||
kind: WorkerInputKind::Notify,
|
||||
content: content.into(),
|
||||
submission_id: None,
|
||||
submission_request_id: None,
|
||||
segments: None,
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,21 @@ impl WorkerInput {
|
||||
mod tests {
|
||||
use super::WorkerInput;
|
||||
|
||||
#[test]
|
||||
fn submission_request_id_round_trips_for_authenticated_client_retry() {
|
||||
let input: WorkerInput = serde_json::from_value(serde_json::json!({
|
||||
"kind": "user",
|
||||
"content": "message",
|
||||
"submission_request_id": "request-1"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(input.submission_request_id.as_deref(), Some("request-1"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(input).unwrap()["submission_request_id"],
|
||||
"request-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_is_an_operation_and_legacy_system_kind_is_rejected() {
|
||||
assert_eq!(
|
||||
@@ -78,4 +93,7 @@ mod tests {
|
||||
pub struct WorkerInteractionAck {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub status: WorkerStatus,
|
||||
/// Present for User Submit and absent for non-Submit interactions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submission: Option<crate::execution::WorkerSubmissionAck>,
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
+769
-245
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,9 @@
|
||||
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};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
|
||||
"application/vnd.yoi.profile-source-archive+tar";
|
||||
@@ -15,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);
|
||||
}
|
||||
}
|
||||
@@ -33,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()
|
||||
}
|
||||
@@ -144,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}")]
|
||||
@@ -163,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")]
|
||||
@@ -174,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
|
||||
}
|
||||
}
|
||||
@@ -208,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}"),
|
||||
@@ -266,23 +305,6 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ProfileSourceArchiveCache {
|
||||
archives: Mutex<HashMap<String, ProfileSourceArchive>>,
|
||||
}
|
||||
|
||||
impl ProfileSourceArchiveCache {
|
||||
pub fn get(&self, digest: &str) -> Option<ProfileSourceArchive> {
|
||||
self.archives.lock().ok()?.get(digest).cloned()
|
||||
}
|
||||
|
||||
pub fn insert(&self, archive: ProfileSourceArchive) {
|
||||
if let Ok(mut archives) = self.archives.lock() {
|
||||
archives.insert(archive.reference.digest.clone(), archive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_profile_source_archive_fetch_request(
|
||||
handle: BackendResourceHandle,
|
||||
runtime_id: &str,
|
||||
@@ -364,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;
|
||||
|
||||
@@ -401,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";
|
||||
|
||||
+1115
-336
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"));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ protocol = { workspace = true, features = ["json-schema"] }
|
||||
client = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
@@ -42,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"] }
|
||||
@@ -52,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 }
|
||||
|
||||
@@ -101,7 +101,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Send a run method
|
||||
handle
|
||||
.send(Method::run_text("What is the capital of France?"))
|
||||
.send(Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"What is the capital of France?",
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Wait for completion
|
||||
|
||||
@@ -22,7 +22,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::Item;
|
||||
use agen::interceptor::{Interceptor, PreRequestAction, PreToolAction, ToolCallInfo};
|
||||
use agen::interceptor::{
|
||||
Interceptor, InterceptorResult, PreLlmRequestContext, PreRequestAction, PreToolAction,
|
||||
ToolCallInfo,
|
||||
};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
@@ -397,15 +400,19 @@ impl CompactWorkerInterceptor {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for CompactWorkerInterceptor {
|
||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
context: PreLlmRequestContext<'_, A>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
let context = context.items;
|
||||
let records = self.usage_tracker.records();
|
||||
let estimate = agen::token_counter::total_tokens(context, &records);
|
||||
if estimate.tokens > self.max_input_tokens {
|
||||
return PreRequestAction::Cancel(format!(
|
||||
return Ok(PreRequestAction::Cancel(format!(
|
||||
"compact worker input occupancy exceeded {} tokens",
|
||||
self.max_input_tokens
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
|
||||
@@ -413,25 +420,28 @@ impl Interceptor for CompactWorkerInterceptor {
|
||||
.store(remaining, Ordering::Release);
|
||||
if let Some(item) = self.maybe_emit_warning(remaining) {
|
||||
self.usage_tracker.note_request(context.len() + 1);
|
||||
return PreRequestAction::ContinueWith(vec![item]);
|
||||
return Ok(PreRequestAction::ContinueWith(vec![item]));
|
||||
}
|
||||
|
||||
self.usage_tracker.note_request(context.len());
|
||||
PreRequestAction::Continue
|
||||
Ok(PreRequestAction::Continue)
|
||||
}
|
||||
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, A>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
if self.final_reserve_tokens == 0 || info.call.name == "write_summary" {
|
||||
return PreToolAction::Continue;
|
||||
return Ok(PreToolAction::Continue);
|
||||
}
|
||||
let remaining = self.last_remaining_tokens.load(Ordering::Acquire);
|
||||
if remaining > self.final_reserve_tokens {
|
||||
return PreToolAction::Continue;
|
||||
return Ok(PreToolAction::Continue);
|
||||
}
|
||||
PreToolAction::SyntheticResult(ToolResult::error(
|
||||
Ok(PreToolAction::SyntheticResult(ToolResult::error(
|
||||
info.call.id.clone(),
|
||||
"compact worker final reserve reached; do not perform more exploratory tool reads. Call `write_summary` now.",
|
||||
))
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,13 +477,27 @@ mod tests {
|
||||
let mut context = vec![Item::user_message("hello")];
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
tracker.record_usage(&make_usage(100));
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
tracker.record_usage(&make_usage(100));
|
||||
@@ -481,7 +505,14 @@ mod tests {
|
||||
// Two 100-token requests would exceed a cumulative 150-token cap, but
|
||||
// current occupancy is still the latest 100-token measurement.
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
}
|
||||
@@ -503,13 +534,27 @@ mod tests {
|
||||
let mut context = vec![Item::user_message("hello")];
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
tracker.record_usage(&make_usage(100));
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::ContinueWith(items)
|
||||
if items.len() == 1 && items[0].as_text().unwrap_or_default().contains("write_summary")
|
||||
));
|
||||
@@ -523,13 +568,27 @@ mod tests {
|
||||
let mut context = vec![Item::user_message("hello")];
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
tracker.record_usage(&make_usage(100));
|
||||
|
||||
assert!(matches!(
|
||||
interceptor.pre_llm_request(&mut context).await,
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Cancel(message) if message.contains("occupancy")
|
||||
));
|
||||
}
|
||||
|
||||
+1520
-302
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
@@ -779,10 +780,10 @@ async fn probe_socket(socket_path: &Path) -> LiveInfo {
|
||||
loop {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
|
||||
Ok(Ok(Some(Event::Snapshot {
|
||||
status: snapshot_status,
|
||||
state: snapshot_state,
|
||||
..
|
||||
}))) => {
|
||||
status = Some(snapshot_status);
|
||||
status = Some(snapshot_state.catalog_status());
|
||||
break;
|
||||
}
|
||||
Ok(Ok(Some(Event::Alert(_)))) => continue,
|
||||
@@ -1012,7 +1013,19 @@ async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()>
|
||||
}
|
||||
|
||||
async fn send_notify(socket_path: &Path, message: String, auto_run: bool) -> io::Result<()> {
|
||||
connect_and_send(socket_path, &Method::Notify { message, auto_run }).await
|
||||
let notification_request_id = protocol::new_submission_request_id();
|
||||
connect_and_send(
|
||||
socket_path,
|
||||
&Method::NotifyTracked {
|
||||
notification_request_id: notification_request_id.clone(),
|
||||
message,
|
||||
auto_run,
|
||||
source: protocol::AuthenticatedInputSource::Backend {
|
||||
operation_id: notification_request_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn json_content<T: Serialize>(value: &T) -> Result<String, ToolError> {
|
||||
@@ -1312,6 +1325,7 @@ mod tests {
|
||||
target: root.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
active_child_segment,
|
||||
)
|
||||
@@ -1482,6 +1496,7 @@ mod tests {
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
@@ -1494,7 +1509,7 @@ mod tests {
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
})
|
||||
@@ -1517,6 +1532,7 @@ mod tests {
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
@@ -1529,14 +1545,17 @@ mod tests {
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let method = reader.next::<Method>().await.unwrap().unwrap();
|
||||
if let Method::Notify { message, auto_run } = method {
|
||||
if let Method::NotifyTracked {
|
||||
message, auto_run, ..
|
||||
} = method
|
||||
{
|
||||
assert!(auto_run);
|
||||
tx.send(message).await.unwrap();
|
||||
} else {
|
||||
@@ -1608,6 +1627,7 @@ mod tests {
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
@@ -1620,7 +1640,7 @@ mod tests {
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
})
|
||||
@@ -1634,6 +1654,7 @@ mod tests {
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
@@ -1646,14 +1667,17 @@ mod tests {
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let method = reader.next::<Method>().await.unwrap().unwrap();
|
||||
if let Method::Notify { message, auto_run } = method {
|
||||
if let Method::NotifyTracked {
|
||||
message, auto_run, ..
|
||||
} = method
|
||||
{
|
||||
assert!(!auto_run);
|
||||
tx.send(message).await.unwrap();
|
||||
} else {
|
||||
@@ -1738,6 +1762,7 @@ mod tests {
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
@@ -1750,7 +1775,7 @@ mod tests {
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: WorkerStatus::Paused,
|
||||
state: WorkerStatus::Paused.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
})
|
||||
@@ -1772,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"),
|
||||
}
|
||||
@@ -1790,6 +1816,8 @@ mod tests {
|
||||
let _ = writer
|
||||
.write(&Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(
|
||||
),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
@@ -1802,7 +1830,7 @@ mod tests {
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+417
-149
@@ -23,7 +23,14 @@ use agen::tool::ToolDefinition;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::hook::{Hook, HookRegistryBuilder, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall};
|
||||
use crate::hook::{
|
||||
BeforeSessionRewrite, Hook, HookExecutionPolicy, HookRegistryBuilder, OnPromptSubmit,
|
||||
OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, RunCommitted, RunExit, WorkerStopping,
|
||||
};
|
||||
use background::{
|
||||
BackgroundTaskSpec, FeatureBackgroundTask, FeatureBackgroundTaskRegistry,
|
||||
FeatureBackgroundTaskRegistryBuilder,
|
||||
};
|
||||
|
||||
/// Stable source-qualified identifier for a feature module.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -253,10 +260,15 @@ pub enum FeatureRuntimeKind {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeatureHookPoint {
|
||||
PreRequest,
|
||||
PromptSubmit,
|
||||
PreLlmRequest,
|
||||
PreToolCall,
|
||||
ToolResult,
|
||||
TurnEnd,
|
||||
PostToolCall,
|
||||
AssistantTurnEnd,
|
||||
RunExit,
|
||||
RunCommitted,
|
||||
BeforeSessionRewrite,
|
||||
WorkerStopping,
|
||||
}
|
||||
|
||||
/// Serializable declaration of a tool contribution. The executable factory is
|
||||
@@ -379,16 +391,17 @@ impl FeatureInstructionContribution {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task lifecycle phase represented by this registry slice.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// Background tasks are always Worker-managed and execute inside the owning
|
||||
/// feature scope. Report-only and detached host-managed declarations are not
|
||||
/// accepted by the current contract.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackgroundTaskLifecycle {
|
||||
DescriptorOnly,
|
||||
HostManaged,
|
||||
WorkerManaged,
|
||||
}
|
||||
|
||||
/// Declaration for a feature-provided background task.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// Declaration for a feature-provided executable background task.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct BackgroundTaskDeclaration {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
@@ -396,11 +409,11 @@ pub struct BackgroundTaskDeclaration {
|
||||
}
|
||||
|
||||
impl BackgroundTaskDeclaration {
|
||||
pub fn descriptor_only(name: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
pub fn worker_managed(name: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
lifecycle: BackgroundTaskLifecycle::DescriptorOnly,
|
||||
lifecycle: BackgroundTaskLifecycle::WorkerManaged,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -772,6 +785,15 @@ impl FeatureInstallReport {
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_installed_contributions(&mut self) {
|
||||
self.installed = false;
|
||||
self.installed_tools.clear();
|
||||
self.installed_hooks.clear();
|
||||
self.installed_instructions.clear();
|
||||
self.declared_background_tasks.clear();
|
||||
self.provided_services.clear();
|
||||
}
|
||||
|
||||
fn mark_skipped(
|
||||
&mut self,
|
||||
kind: FeatureContributionKind,
|
||||
@@ -881,46 +903,6 @@ fn reject_undeclared_contribution(
|
||||
error
|
||||
}
|
||||
|
||||
/// Model-visible durable notification sink skeleton. The first slice exposes
|
||||
/// the boundary without implementing a new event channel.
|
||||
pub struct FeatureNotificationSink<'a> {
|
||||
report: &'a mut FeatureInstallReport,
|
||||
}
|
||||
|
||||
impl FeatureNotificationSink<'_> {
|
||||
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
|
||||
let message = message.into();
|
||||
self.report.diagnostics.push(FeatureDiagnostic::warning(format!(
|
||||
"model notification requested during feature installation but no durable Notify host is attached: {message}"
|
||||
)));
|
||||
self.report.mark_skipped(
|
||||
FeatureContributionKind::Notification,
|
||||
"notify_model",
|
||||
"durable Notify/SystemItem host is not connected during feature installation",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient human-facing alert sink skeleton.
|
||||
pub struct FeatureAlertSink<'a> {
|
||||
report: &'a mut FeatureInstallReport,
|
||||
}
|
||||
|
||||
impl FeatureAlertSink<'_> {
|
||||
pub fn alert(&mut self, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
self.report
|
||||
.diagnostics
|
||||
.push(FeatureDiagnostic::info(format!("feature alert: {message}")));
|
||||
self.report.mark_skipped(
|
||||
FeatureContributionKind::Alert,
|
||||
"alert",
|
||||
"transient alert host is not connected during feature installation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostic sink available to feature installers.
|
||||
pub struct FeatureDiagnosticSink<'a> {
|
||||
report: &'a mut FeatureInstallReport,
|
||||
@@ -1042,15 +1024,74 @@ impl HookContributionRegistrar<'_> {
|
||||
))
|
||||
}
|
||||
|
||||
fn record(&mut self, declaration: HookDeclaration) {
|
||||
if !self.report.installed_hooks.contains(&declaration) {
|
||||
self.report.installed_hooks.push(declaration);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_prompt_submit(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<OnPromptSubmit> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PromptSubmit);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder
|
||||
.add_named_on_prompt_submit(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_pre_llm_request(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<PreLlmRequest> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreLlmRequest);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder
|
||||
.add_named_pre_llm_request(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_pre_request(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
hook: impl Hook<PreLlmRequest> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreRequest);
|
||||
self.add_pre_llm_request(name, HookExecutionPolicy::fail_closed(), hook)
|
||||
}
|
||||
|
||||
pub fn add_pre_tool_call_with_policy(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<PreToolCall> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreToolCall);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder.add_pre_llm_request(hook);
|
||||
self.report.installed_hooks.push(declaration);
|
||||
self.hook_builder
|
||||
.add_named_pre_tool_call(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1059,10 +1100,25 @@ impl HookContributionRegistrar<'_> {
|
||||
name: impl Into<String>,
|
||||
hook: impl Hook<PreToolCall> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreToolCall);
|
||||
self.add_pre_tool_call_with_policy(name, HookExecutionPolicy::fail_closed(), hook)
|
||||
}
|
||||
|
||||
pub fn add_post_tool_call(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<PostToolCall> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::PostToolCall);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder.add_pre_tool_call(hook);
|
||||
self.report.installed_hooks.push(declaration);
|
||||
self.hook_builder
|
||||
.add_named_post_tool_call(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1071,10 +1127,25 @@ impl HookContributionRegistrar<'_> {
|
||||
name: impl Into<String>,
|
||||
hook: impl Hook<PostToolCall> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::ToolResult);
|
||||
self.add_post_tool_call(name, HookExecutionPolicy::fail_closed(), hook)
|
||||
}
|
||||
|
||||
pub fn add_assistant_turn_end(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<OnTurnEnd> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::AssistantTurnEnd);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder.add_post_tool_call(hook);
|
||||
self.report.installed_hooks.push(declaration);
|
||||
self.hook_builder
|
||||
.add_named_on_turn_end(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1083,10 +1154,82 @@ impl HookContributionRegistrar<'_> {
|
||||
name: impl Into<String>,
|
||||
hook: impl Hook<OnTurnEnd> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::TurnEnd);
|
||||
self.add_assistant_turn_end(name, HookExecutionPolicy::fail_closed(), hook)
|
||||
}
|
||||
|
||||
pub fn add_run_exit(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<RunExit> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::RunExit);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder.add_on_turn_end(hook);
|
||||
self.report.installed_hooks.push(declaration);
|
||||
self.hook_builder
|
||||
.add_named_run_exit(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_run_committed(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<RunCommitted> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::RunCommitted);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder
|
||||
.add_named_run_committed(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_before_session_rewrite(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<BeforeSessionRewrite> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::BeforeSessionRewrite);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder
|
||||
.add_named_before_session_rewrite(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_worker_stopping(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
policy: HookExecutionPolicy,
|
||||
hook: impl Hook<WorkerStopping> + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = HookDeclaration::new(name, FeatureHookPoint::WorkerStopping);
|
||||
self.require_declared(&declaration)?;
|
||||
self.hook_builder
|
||||
.add_named_worker_stopping(
|
||||
format!("{}:{}", self.feature_id, declaration.name),
|
||||
policy,
|
||||
hook,
|
||||
)
|
||||
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
|
||||
self.record(declaration);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1124,33 +1267,40 @@ impl FeatureInstructionRegistrar<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task registrar for descriptor/report-only contributions.
|
||||
/// Registrar for executable, Worker-managed background task contributions.
|
||||
pub struct BackgroundTaskRegistrar<'a> {
|
||||
feature_id: &'a FeatureId,
|
||||
declarations: &'a FeatureContributionDeclarations,
|
||||
registry: &'a mut FeatureBackgroundTaskRegistryBuilder,
|
||||
report: &'a mut FeatureInstallReport,
|
||||
}
|
||||
|
||||
impl BackgroundTaskRegistrar<'_> {
|
||||
pub fn declare(
|
||||
pub fn register(
|
||||
&mut self,
|
||||
declaration: BackgroundTaskDeclaration,
|
||||
spec: BackgroundTaskSpec,
|
||||
task: impl FeatureBackgroundTask + 'static,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
if !self.declarations.contains_background_task(&declaration) {
|
||||
if !self
|
||||
.declarations
|
||||
.contains_background_task(&spec.declaration)
|
||||
{
|
||||
return Err(reject_undeclared_contribution(
|
||||
self.feature_id,
|
||||
self.report,
|
||||
FeatureContributionKind::BackgroundTask,
|
||||
declaration.name,
|
||||
spec.declaration.name,
|
||||
));
|
||||
}
|
||||
self.registry
|
||||
.register(self.feature_id.clone(), spec.clone(), task)?;
|
||||
if !self
|
||||
.report
|
||||
.declared_background_tasks
|
||||
.iter()
|
||||
.any(|task| task.name == declaration.name)
|
||||
.any(|task| task.name == spec.declaration.name)
|
||||
{
|
||||
self.report.declared_background_tasks.push(declaration);
|
||||
self.report.declared_background_tasks.push(spec.declaration);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1330,15 +1480,17 @@ impl ProtocolProviderRegistrar<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
for task in background_tasks {
|
||||
if !self
|
||||
.report
|
||||
.declared_background_tasks
|
||||
.iter()
|
||||
.any(|declared| declared.name == task.name)
|
||||
{
|
||||
self.report.declared_background_tasks.push(task);
|
||||
}
|
||||
if let Some(task) = background_tasks.first() {
|
||||
let reason = format!(
|
||||
"protocol provider background task `{}` has no executable Worker-managed handler",
|
||||
task.name
|
||||
);
|
||||
self.report.mark_skipped(
|
||||
FeatureContributionKind::BackgroundTask,
|
||||
task.name.clone(),
|
||||
reason.clone(),
|
||||
);
|
||||
return Err(FeatureInstallError::InvalidDescriptor(reason));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1352,6 +1504,7 @@ pub struct FeatureInstallContext<'a> {
|
||||
pending_tools: &'a mut Vec<ToolDefinition>,
|
||||
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
||||
hook_builder: &'a mut HookRegistryBuilder,
|
||||
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
|
||||
service_registry: &'a mut FeatureServiceRegistry,
|
||||
report: &'a mut FeatureInstallReport,
|
||||
}
|
||||
@@ -1392,6 +1545,7 @@ impl FeatureInstallContext<'_> {
|
||||
BackgroundTaskRegistrar {
|
||||
feature_id: self.feature_id,
|
||||
declarations: self.declarations,
|
||||
registry: self.background_task_builder,
|
||||
report: self.report,
|
||||
}
|
||||
}
|
||||
@@ -1416,18 +1570,6 @@ impl FeatureInstallContext<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
|
||||
FeatureNotificationSink {
|
||||
report: self.report,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alerts(&mut self) -> FeatureAlertSink<'_> {
|
||||
FeatureAlertSink {
|
||||
report: self.report,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
|
||||
FeatureDiagnosticSink {
|
||||
report: self.report,
|
||||
@@ -1440,6 +1582,7 @@ impl FeatureInstallContext<'_> {
|
||||
pub struct FeatureRegistryInstallReport {
|
||||
pub reports: Vec<FeatureInstallReport>,
|
||||
pub services: FeatureServiceRegistry,
|
||||
pub background_tasks: FeatureBackgroundTaskRegistry,
|
||||
pub plan_error: Option<FeaturePlanError>,
|
||||
}
|
||||
|
||||
@@ -1795,7 +1938,7 @@ impl FeatureRegistryBuilder {
|
||||
}
|
||||
|
||||
/// Install modules into the existing Engine tool path and hook builder.
|
||||
pub(crate) fn install_into_engine<C: LlmClient, A>(
|
||||
pub(crate) fn install_into_engine<C: LlmClient, A: Send + Sync>(
|
||||
self,
|
||||
worker: &mut Engine<C, Mutable, A>,
|
||||
hook_builder: &mut HookRegistryBuilder,
|
||||
@@ -1861,12 +2004,16 @@ impl FeatureRegistryBuilder {
|
||||
return FeatureRegistryInstallReport {
|
||||
reports,
|
||||
services: FeatureServiceRegistry::default(),
|
||||
background_tasks: FeatureBackgroundTaskRegistry::default(),
|
||||
plan_error: Some(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut service_registry = FeatureServiceRegistry::default();
|
||||
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
|
||||
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
|
||||
let install_hook_checkpoint = hook_builder.checkpoint();
|
||||
let install_tool_checkpoint = pending_tools.len();
|
||||
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
|
||||
let ordered_modules = plan
|
||||
.ordered_indices
|
||||
@@ -1884,6 +2031,11 @@ impl FeatureRegistryBuilder {
|
||||
for (module, descriptor) in ordered_modules {
|
||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||
let mut report = FeatureInstallReport::new(&descriptor);
|
||||
let hook_checkpoint = hook_builder.checkpoint();
|
||||
let background_checkpoint = background_task_builder.checkpoint();
|
||||
let service_checkpoint = service_registry.clone();
|
||||
let tool_checkpoint = pending_tools.len();
|
||||
let installed_tool_checkpoint = installed_tool_names.clone();
|
||||
|
||||
let mut required_service_failed = false;
|
||||
for requirement in descriptor.requires_services.iter().cloned() {
|
||||
@@ -1920,10 +2072,6 @@ impl FeatureRegistryBuilder {
|
||||
continue;
|
||||
}
|
||||
|
||||
for background_task in descriptor.background_tasks.iter().cloned() {
|
||||
report.declared_background_tasks.push(background_task);
|
||||
}
|
||||
|
||||
let install_result = {
|
||||
let mut context = FeatureInstallContext {
|
||||
feature_id: &descriptor.id,
|
||||
@@ -1931,6 +2079,7 @@ impl FeatureRegistryBuilder {
|
||||
pending_tools,
|
||||
installed_tool_names: &mut installed_tool_names,
|
||||
hook_builder,
|
||||
background_task_builder: &mut background_task_builder,
|
||||
service_registry: &mut service_registry,
|
||||
report: &mut report,
|
||||
};
|
||||
@@ -1940,18 +2089,81 @@ impl FeatureRegistryBuilder {
|
||||
match install_result {
|
||||
Ok(()) => report.installed = true,
|
||||
Err(error) => {
|
||||
hook_builder.rollback_to(hook_checkpoint);
|
||||
background_task_builder.rollback_to(&background_checkpoint);
|
||||
service_registry = service_checkpoint.clone();
|
||||
pending_tools.truncate(tool_checkpoint);
|
||||
installed_tool_names = installed_tool_checkpoint.clone();
|
||||
report.clear_installed_contributions();
|
||||
report
|
||||
.diagnostics
|
||||
.push(FeatureDiagnostic::error(error.to_string()));
|
||||
}
|
||||
}
|
||||
if report.installed {
|
||||
for hook in &descriptor.hooks {
|
||||
if !report.installed_hooks.contains(hook) {
|
||||
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
||||
"feature `{}` declared hook `{}` at {:?} but did not register it",
|
||||
descriptor.id, hook.name, hook.point
|
||||
)));
|
||||
}
|
||||
}
|
||||
for task in &descriptor.background_tasks {
|
||||
if !report.declared_background_tasks.contains(task) {
|
||||
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
||||
"feature `{}` declared background task `{}` but did not register an executable handler",
|
||||
descriptor.id, task.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
if report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
{
|
||||
hook_builder.rollback_to(hook_checkpoint);
|
||||
background_task_builder.rollback_to(&background_checkpoint);
|
||||
service_registry = service_checkpoint.clone();
|
||||
pending_tools.truncate(tool_checkpoint);
|
||||
installed_tool_names = installed_tool_checkpoint.clone();
|
||||
report.clear_installed_contributions();
|
||||
report.clear_installed_contributions();
|
||||
}
|
||||
}
|
||||
reports.push(report);
|
||||
}
|
||||
|
||||
FeatureRegistryInstallReport {
|
||||
reports,
|
||||
services: service_registry,
|
||||
plan_error: None,
|
||||
let failed = reports.iter().any(|report| {
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||
});
|
||||
if failed {
|
||||
hook_builder.rollback_to(install_hook_checkpoint);
|
||||
pending_tools.truncate(install_tool_checkpoint);
|
||||
for report in &mut reports {
|
||||
if report.installed {
|
||||
report.clear_installed_contributions();
|
||||
report.diagnostics.push(FeatureDiagnostic::warning(
|
||||
"feature scope rolled back because another contribution failed",
|
||||
));
|
||||
}
|
||||
}
|
||||
FeatureRegistryInstallReport {
|
||||
reports,
|
||||
services: FeatureServiceRegistry::default(),
|
||||
background_tasks: FeatureBackgroundTaskRegistry::default(),
|
||||
plan_error: None,
|
||||
}
|
||||
} else {
|
||||
FeatureRegistryInstallReport {
|
||||
reports,
|
||||
services: service_registry,
|
||||
background_tasks: background_task_builder.build(),
|
||||
plan_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1996,9 +2208,10 @@ pub enum FeatureInstallError {
|
||||
Install(String),
|
||||
}
|
||||
|
||||
pub mod background;
|
||||
pub mod builtin;
|
||||
pub mod mcp;
|
||||
pub mod plugin;
|
||||
pub(crate) mod session;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -2010,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;
|
||||
|
||||
@@ -2398,13 +2636,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_contributions_are_recorded() {
|
||||
fn executable_contributions_are_recorded() {
|
||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"))
|
||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
||||
"daily",
|
||||
"descriptor-only background task",
|
||||
));
|
||||
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"));
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
@@ -2420,7 +2654,7 @@ mod tests {
|
||||
let feature_report = &report.reports[0];
|
||||
assert!(feature_report.installed);
|
||||
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
|
||||
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
|
||||
assert!(feature_report.declared_background_tasks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2480,8 +2714,9 @@ mod tests {
|
||||
})
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
|
||||
assert_eq!(pending_tools.len(), 1);
|
||||
assert!(report.reports[0].installed);
|
||||
assert!(pending_tools.is_empty());
|
||||
assert!(!report.reports[0].installed);
|
||||
assert!(report.reports[0].installed_tools.is_empty());
|
||||
assert!(!report.reports[1].installed);
|
||||
assert!(
|
||||
report.reports[1]
|
||||
@@ -2558,7 +2793,7 @@ mod tests {
|
||||
"1.0.0",
|
||||
"startup-discovered service",
|
||||
))
|
||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
||||
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||
"provider-poller",
|
||||
"provider lifecycle poller",
|
||||
))
|
||||
@@ -2568,7 +2803,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_provider_registers_startup_discovered_contributions_through_worker_path() {
|
||||
fn protocol_provider_report_only_background_task_is_rejected_atomically() {
|
||||
let provider = ProtocolProviderDeclaration::new(
|
||||
ProviderId::builtin("dynamic-provider"),
|
||||
"test-protocol",
|
||||
@@ -2599,30 +2834,18 @@ mod tests {
|
||||
.collect();
|
||||
let feature_report = &report.reports[0];
|
||||
|
||||
assert!(feature_report.installed);
|
||||
assert_eq!(feature_report.installed_tools, vec!["DynamicTool"]);
|
||||
assert_eq!(tool_names, vec!["DynamicTool"]);
|
||||
assert!(!feature_report.installed);
|
||||
assert!(feature_report.installed_tools.is_empty());
|
||||
assert!(tool_names.is_empty());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(feature_report.provided_services.len(), 1);
|
||||
assert_eq!(
|
||||
feature_report.provided_services[0].id,
|
||||
ServiceId::builtin("dynamic-service")
|
||||
);
|
||||
assert_eq!(
|
||||
feature_report.declared_background_tasks[0].name,
|
||||
"provider-poller"
|
||||
);
|
||||
assert!(feature_report.provided_services.is_empty());
|
||||
assert!(feature_report.declared_background_tasks.is_empty());
|
||||
assert_eq!(feature_report.protocol_providers.len(), 1);
|
||||
assert_eq!(
|
||||
feature_report.protocol_providers[0].state,
|
||||
ProtocolProviderLifecycleState::Ready
|
||||
);
|
||||
assert!(
|
||||
feature_report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.message.contains("startup discovery completed"))
|
||||
);
|
||||
assert!(feature_report.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic
|
||||
.message
|
||||
.contains("has no executable Worker-managed handler")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2779,8 +3002,8 @@ mod tests {
|
||||
async fn call(
|
||||
&self,
|
||||
_input: &crate::hook::ToolCallSummary,
|
||||
) -> crate::hook::HookPreToolAction {
|
||||
crate::hook::HookPreToolAction::Continue
|
||||
) -> Result<crate::hook::HookPreToolAction, crate::hook::HookError> {
|
||||
Ok(crate::hook::HookPreToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2804,6 +3027,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopBackgroundTask;
|
||||
|
||||
#[async_trait]
|
||||
impl FeatureBackgroundTask for NoopBackgroundTask {
|
||||
async fn run(
|
||||
&self,
|
||||
_context: background::BackgroundTaskContext,
|
||||
_cancellation: background::BackgroundTaskCancellation,
|
||||
) -> Result<(), crate::hook::HookError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct BackgroundFeature {
|
||||
descriptor: FeatureDescriptor,
|
||||
task_name: &'static str,
|
||||
@@ -2818,12 +3054,22 @@ mod tests {
|
||||
&self,
|
||||
context: &mut FeatureInstallContext<'_>,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
context
|
||||
.background_tasks()
|
||||
.declare(BackgroundTaskDeclaration::descriptor_only(
|
||||
self.task_name,
|
||||
"runtime background task",
|
||||
))
|
||||
let declaration = self
|
||||
.descriptor
|
||||
.background_tasks
|
||||
.iter()
|
||||
.find(|task| task.name == self.task_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
BackgroundTaskDeclaration::worker_managed(
|
||||
self.task_name,
|
||||
"undeclared background task",
|
||||
)
|
||||
});
|
||||
context.background_tasks().register(
|
||||
BackgroundTaskSpec::single_flight(declaration, std::time::Duration::from_secs(1)),
|
||||
NoopBackgroundTask,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2985,25 +3231,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_task_declaration_is_descriptor_contribution() {
|
||||
#[tokio::test]
|
||||
async fn executable_background_task_is_registered_in_worker_scope() {
|
||||
let descriptor = FeatureDescriptor::builtin("background", "Background")
|
||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
||||
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||
"declared-task",
|
||||
"descriptor contribution",
|
||||
));
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ServiceFeature { descriptor })
|
||||
.with_module(BackgroundFeature {
|
||||
descriptor,
|
||||
task_name: "declared-task",
|
||||
})
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
|
||||
assert!(report.reports[0].installed);
|
||||
assert_eq!(
|
||||
report.reports[0].declared_background_tasks[0].name,
|
||||
"declared-task"
|
||||
);
|
||||
assert!(report.reports[0].skipped.is_empty());
|
||||
assert!(matches!(
|
||||
report
|
||||
.background_tasks
|
||||
.start(
|
||||
&FeatureId::builtin("background"),
|
||||
"declared-task",
|
||||
crate::hook::HookInvocationContext::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
background::BackgroundTaskStart::Started { .. }
|
||||
));
|
||||
report.background_tasks.shutdown().await.unwrap();
|
||||
assert!(matches!(
|
||||
report.background_tasks.diagnostics()[0].outcome,
|
||||
background::BackgroundTaskOutcome::Completed
|
||||
| background::BackgroundTaskOutcome::Cancelled
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3118,7 +3383,10 @@ mod tests {
|
||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||
assert_eq!(
|
||||
hook_points,
|
||||
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
|
||||
vec![
|
||||
FeatureHookPoint::PreLlmRequest,
|
||||
FeatureHookPoint::PreToolCall
|
||||
]
|
||||
);
|
||||
assert!(descriptor.background_tasks.is_empty());
|
||||
assert!(descriptor.provides_services.is_empty());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@ pub mod flow_transition;
|
||||
pub mod manage_workdir;
|
||||
pub mod manage_worker;
|
||||
pub mod memory;
|
||||
pub mod memory_extract;
|
||||
pub(crate) mod memory_lifecycle;
|
||||
pub mod memory_staging_output;
|
||||
pub mod merge_request;
|
||||
pub mod objective;
|
||||
pub mod orchestration;
|
||||
@@ -19,8 +20,6 @@ pub mod ticket;
|
||||
pub mod worker_observation;
|
||||
pub mod workspace_worker_discovery;
|
||||
|
||||
pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
|
||||
pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
|
||||
pub use task::{TaskFeature, task_tools_feature};
|
||||
pub use ticket::{
|
||||
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! endpoints, credentials, materializer handles, and operation sessions stay
|
||||
//! behind [`WorkspaceClient`].
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -12,11 +14,12 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
|
||||
use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -52,16 +55,48 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac
|
||||
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
|
||||
const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session.";
|
||||
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
|
||||
pub(crate) type BeforeWorkdirRelease =
|
||||
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = std::io::Result<()>> + Send>> + Send + Sync>;
|
||||
pub(crate) type AfterWorkdirAttach = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct ManageWorkdirFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ManageWorkdirFeature {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ManageWorkdirFeature")
|
||||
.field("client_kind", &self.client.kind())
|
||||
.field("release_guard", &self.before_workdir_release.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ManageWorkdirFeature {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
Self {
|
||||
client,
|
||||
before_workdir_release: None,
|
||||
after_workdir_attach: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_child_lifecycle(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
before_workdir_release: BeforeWorkdirRelease,
|
||||
after_workdir_attach: AfterWorkdirAttach,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
before_workdir_release: Some(before_workdir_release),
|
||||
after_workdir_attach: Some(after_workdir_attach),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +116,10 @@ impl FeatureModule for ManageWorkdirFeature {
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone());
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()).with_child_lifecycle(
|
||||
self.before_workdir_release.clone(),
|
||||
self.after_workdir_attach.clone(),
|
||||
);
|
||||
for (name, definition) in [
|
||||
(
|
||||
LIST_TOOL,
|
||||
@@ -142,9 +180,21 @@ impl FeatureModule for ManageWorkdirFeature {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
struct WorkspaceHttpWorkdirBackend {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkspaceHttpWorkdirBackend {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("WorkspaceHttpWorkdirBackend")
|
||||
.field("client_kind", &self.client.kind())
|
||||
.field("release_guard", &self.before_workdir_release.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker-local Workdir handle whose operation authority remains in the Workspace Backend.
|
||||
@@ -156,8 +206,6 @@ struct WorkspaceHttpWorkdirBackend {
|
||||
pub struct WorkspaceAttachedWorkdirSession {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
workdir: Workdir,
|
||||
expected_session_fence: Option<String>,
|
||||
delegations: Vec<workdir::WorkdirDelegationRequest>,
|
||||
}
|
||||
|
||||
impl WorkspaceAttachedWorkdirSession {
|
||||
@@ -165,8 +213,6 @@ impl WorkspaceAttachedWorkdirSession {
|
||||
Arc::new(Self {
|
||||
client,
|
||||
workdir: Workdir::new("workspace-attachment"),
|
||||
expected_session_fence: None,
|
||||
delegations: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,16 +229,13 @@ impl WorkspaceAttachedWorkdirSession {
|
||||
"/api/w/{}/workers/self/workdir-session/operations",
|
||||
encode_path_segment(workspace_id)
|
||||
),
|
||||
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest {
|
||||
expected_session_fence: self.expected_session_fence.clone(),
|
||||
delegations: self.delegations.clone(),
|
||||
operation,
|
||||
})
|
||||
.map_err(|error| {
|
||||
WorkdirError::Transport(format!(
|
||||
"failed to encode Workspace Workdir operation: {error}"
|
||||
))
|
||||
})?,
|
||||
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { operation }).map_err(
|
||||
|error| {
|
||||
WorkdirError::Transport(format!(
|
||||
"failed to encode Workspace Workdir operation: {error}"
|
||||
))
|
||||
},
|
||||
)?,
|
||||
);
|
||||
let response = self
|
||||
.client
|
||||
@@ -241,57 +284,24 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
WorkdirSessionCapabilities::ALL
|
||||
}
|
||||
|
||||
fn transports_delegation_context(&self) -> bool {
|
||||
true
|
||||
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 capture_delegation_source(
|
||||
async fn scope_rules_overlap(
|
||||
&self,
|
||||
request: &workdir::WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
let expected_session_fence = if let Some(fence) = &self.expected_session_fence {
|
||||
fence.clone()
|
||||
} else {
|
||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||
WorkdirError::Unavailable("Workspace identity is unavailable".to_string())
|
||||
})?;
|
||||
let response = self
|
||||
.client
|
||||
.execute(WorkspaceRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: format!(
|
||||
"/api/w/{}/workers/self/workdir-session/fence",
|
||||
encode_path_segment(workspace_id)
|
||||
),
|
||||
body: None,
|
||||
})
|
||||
.map_err(|error| {
|
||||
WorkdirError::Unavailable(format!(
|
||||
"failed to capture Workdir attachment fence: {error}"
|
||||
))
|
||||
})?;
|
||||
let fence: WorkspaceWorkdirSessionFence = serde_json::from_str(&response.body)
|
||||
.map_err(|error| {
|
||||
WorkdirError::Unavailable(format!(
|
||||
"invalid Workdir attachment fence response: {error}"
|
||||
))
|
||||
})?;
|
||||
fence.value
|
||||
};
|
||||
let mut delegations = self.delegations.clone();
|
||||
delegations.push(request.clone());
|
||||
let candidate = Arc::new(Self {
|
||||
client: self.client.clone(),
|
||||
workdir: self.workdir.clone(),
|
||||
expected_session_fence: Some(expected_session_fence),
|
||||
delegations,
|
||||
});
|
||||
candidate
|
||||
.stat(StatRequest {
|
||||
path: workdir::WorkdirPath::new("").expect("empty Workdir path is valid"),
|
||||
})
|
||||
.await?;
|
||||
Ok(candidate)
|
||||
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> {
|
||||
@@ -387,7 +397,21 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
|
||||
impl WorkspaceHttpWorkdirBackend {
|
||||
fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
Self {
|
||||
client,
|
||||
before_workdir_release: None,
|
||||
after_workdir_attach: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_child_lifecycle(
|
||||
mut self,
|
||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||
) -> Self {
|
||||
self.before_workdir_release = before_workdir_release;
|
||||
self.after_workdir_attach = after_workdir_attach;
|
||||
self
|
||||
}
|
||||
|
||||
fn workspace_id(&self) -> Result<&str, ToolError> {
|
||||
@@ -565,11 +589,26 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
||||
parse_input::<WorkdirCreateInput>(input_json)?,
|
||||
ctx.call_id.to_string(),
|
||||
),
|
||||
WorkdirOperation::Attach => self
|
||||
.backend
|
||||
.attach(parse_input::<WorkdirAttachInput>(input_json)?),
|
||||
WorkdirOperation::Attach => {
|
||||
let result = self
|
||||
.backend
|
||||
.attach(parse_input::<WorkdirAttachInput>(input_json)?);
|
||||
if result.is_ok()
|
||||
&& let Some(after_attach) = &self.backend.after_workdir_attach
|
||||
{
|
||||
after_attach();
|
||||
}
|
||||
result
|
||||
}
|
||||
WorkdirOperation::Detach => {
|
||||
let _input = parse_input::<WorkdirDetachInput>(input_json)?;
|
||||
if let Some(before_release) = &self.backend.before_workdir_release {
|
||||
before_release().await.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
self.backend.detach()
|
||||
}
|
||||
WorkdirOperation::Delete => self
|
||||
@@ -765,6 +804,7 @@ struct WorkdirDeleteInput {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::{FeatureModule, FeatureRegistryBuilder};
|
||||
@@ -844,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"
|
||||
},
|
||||
@@ -1155,6 +1195,7 @@ mod tests {
|
||||
command: "true".to_string(),
|
||||
timeout_secs: 120,
|
||||
output_limit: 1024,
|
||||
cwd: None,
|
||||
spill_dir: Some("/worker-local/bash-output".into()),
|
||||
tool_call_id: Some("call-1".to_string()),
|
||||
})
|
||||
@@ -1178,83 +1219,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_attached_session_carries_captured_fence_on_operations() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
response(json!({"value": "attachment-fence"})),
|
||||
response(json!({
|
||||
"operation": "stat",
|
||||
"result": {"path": "", "kind": "directory", "size": 0}
|
||||
})),
|
||||
response(json!({
|
||||
"operation": "stat",
|
||||
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
||||
})),
|
||||
]));
|
||||
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
|
||||
client.clone(),
|
||||
));
|
||||
let delegation = parent
|
||||
.delegate(workdir::WorkdirDelegationRequest {
|
||||
rules: vec![workdir::WorkdirDelegationRule {
|
||||
target: workdir::WorkdirPath::new("").unwrap(),
|
||||
permission: workdir::WorkdirDelegationPermission::Read,
|
||||
recursive: false,
|
||||
}],
|
||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
delegation
|
||||
.scoped_session
|
||||
.stat(StatRequest {
|
||||
path: workdir::WorkdirPath::new("visible.txt").unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = client.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/api/w/workspace%2Ftest/workers/self/workdir-session/fence"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["expected_session_fence"], "attachment-fence");
|
||||
assert_eq!(body["operation"]["operation"], "stat");
|
||||
assert_eq!(body["delegations"][0]["rules"][0]["target"], "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attached_provider_rejection_happens_before_delegation_is_returned() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
response(json!({"value": "attachment-fence"})),
|
||||
response(json!({"error": "provider rejected delegated write target"})),
|
||||
]));
|
||||
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
|
||||
client.clone(),
|
||||
));
|
||||
let result = parent
|
||||
.delegate(workdir::WorkdirDelegationRequest {
|
||||
rules: vec![workdir::WorkdirDelegationRule {
|
||||
target: workdir::WorkdirPath::new("linked-target").unwrap(),
|
||||
permission: workdir::WorkdirDelegationPermission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
cwd: workdir::WorkdirPath::new("linked-target").unwrap(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "provider rejection must fail before lease");
|
||||
let requests = client.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let validation: serde_json::Value =
|
||||
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(validation["operation"]["operation"], "stat");
|
||||
assert_eq!(validation["delegations"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attached_session_preserves_typed_provider_validation_error() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response(
|
||||
@@ -1298,73 +1262,51 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nested_attached_session_preserves_full_delegation_chain() {
|
||||
async fn scoped_broker_operations_carry_no_child_context() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
response(json!({"value": "attachment-fence"})),
|
||||
response(json!({ "operation": "authorize_scope" })),
|
||||
response(json!({ "operation": "authorize_scope" })),
|
||||
response(json!({
|
||||
"operation": "stat",
|
||||
"result": {"path": "", "kind": "directory", "size": 0}
|
||||
})),
|
||||
response(json!({
|
||||
"operation": "stat",
|
||||
"result": {"path": "nested", "kind": "directory", "size": 0}
|
||||
})),
|
||||
response(json!({
|
||||
"operation": "stat",
|
||||
"result": {"path": "nested/file", "kind": "file", "size": 1}
|
||||
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
||||
})),
|
||||
]));
|
||||
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
|
||||
let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle(
|
||||
client.clone(),
|
||||
));
|
||||
let outer = parent
|
||||
.delegate(workdir::WorkdirDelegationRequest {
|
||||
rules: vec![workdir::WorkdirDelegationRule {
|
||||
let scoped = broker
|
||||
.scope(workdir::WorkdirToolScope {
|
||||
rules: vec![workdir::WorkdirToolScopeRule {
|
||||
target: workdir::WorkdirPath::new("").unwrap(),
|
||||
permission: workdir::WorkdirDelegationPermission::Read,
|
||||
permission: workdir::WorkdirToolScopePermission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||
command: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let nested = outer
|
||||
.scoped_session
|
||||
.delegate(workdir::WorkdirDelegationRequest {
|
||||
rules: vec![workdir::WorkdirDelegationRule {
|
||||
target: workdir::WorkdirPath::new("nested").unwrap(),
|
||||
permission: workdir::WorkdirDelegationPermission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
cwd: workdir::WorkdirPath::new("nested").unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
nested
|
||||
.scoped_session
|
||||
scoped
|
||||
.stat(StatRequest {
|
||||
path: workdir::WorkdirPath::new("file").unwrap(),
|
||||
path: workdir::WorkdirPath::new("visible.txt").unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = client.requests();
|
||||
assert_eq!(requests.len(), 4);
|
||||
let outer_validation: serde_json::Value =
|
||||
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
||||
let nested_validation: serde_json::Value =
|
||||
serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(outer_validation["delegations"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
nested_validation["delegations"].as_array().unwrap().len(),
|
||||
2
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[3].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["delegations"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(body["delegations"][0]["rules"][0]["target"], "");
|
||||
assert_eq!(body["delegations"][1]["rules"][0]["target"], "nested");
|
||||
assert_eq!(body["operation"]["request"]["path"], "file");
|
||||
assert_eq!(requests.len(), 3);
|
||||
for request in requests {
|
||||
assert_eq!(
|
||||
request.path,
|
||||
"/api/w/workspace%2Ftest/workers/self/workdir-session/operations"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
assert!(body.get("delegations").is_none());
|
||||
assert!(body.get("child").is_none());
|
||||
assert!(body.get("expected_session_fence").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1416,4 +1358,86 @@ mod tests {
|
||||
assert!(client.requests().is_empty());
|
||||
assert!(parse_input::<WorkdirListInput>(r#"{"path":"/tmp"}"#).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detach_stops_internal_subworkers_before_backend_release() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||
"workspace_id": "workspace/test",
|
||||
"workdir_id": "wd-attached",
|
||||
"attached": false
|
||||
}))]));
|
||||
let cleanup_calls = Arc::new(AtomicUsize::new(0));
|
||||
let cleanup_calls_for_guard = cleanup_calls.clone();
|
||||
let before_release: BeforeWorkdirRelease = Arc::new(move || {
|
||||
let cleanup_calls = cleanup_calls_for_guard.clone();
|
||||
Box::pin(async move {
|
||||
cleanup_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
let tool = WorkspaceHttpWorkdirTool {
|
||||
backend: WorkspaceHttpWorkdirBackend::new(client.clone())
|
||||
.with_child_lifecycle(Some(before_release), None),
|
||||
operation: WorkdirOperation::Detach,
|
||||
};
|
||||
|
||||
tool.execute("{}", ToolExecutionContext::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(client.requests().len(), 1);
|
||||
assert_eq!(
|
||||
client.requests()[0].path,
|
||||
"/api/w/workspace%2Ftest/workers/self/workdir-attachment"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detach_does_not_release_backend_when_child_cleanup_fails() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
|
||||
let before_release: BeforeWorkdirRelease =
|
||||
Arc::new(|| Box::pin(async { Err(std::io::Error::other("child cleanup failed")) }));
|
||||
let tool = WorkspaceHttpWorkdirTool {
|
||||
backend: WorkspaceHttpWorkdirBackend::new(client.clone())
|
||||
.with_child_lifecycle(Some(before_release), None),
|
||||
operation: WorkdirOperation::Detach,
|
||||
};
|
||||
|
||||
let error = tool
|
||||
.execute("{}", ToolExecutionContext::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("stop Internal SubWorkers"));
|
||||
assert!(client.requests().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_attach_reopens_internal_subworker_admission() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||
"workspace_id": "workspace/test",
|
||||
"workdir_id": "wd-attached",
|
||||
"attached": true
|
||||
}))]));
|
||||
let reopen_calls = Arc::new(AtomicUsize::new(0));
|
||||
let reopen_calls_for_hook = reopen_calls.clone();
|
||||
let after_attach: AfterWorkdirAttach = Arc::new(move || {
|
||||
reopen_calls_for_hook.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let tool = WorkspaceHttpWorkdirTool {
|
||||
backend: WorkspaceHttpWorkdirBackend::new(client)
|
||||
.with_child_lifecycle(None, Some(after_attach)),
|
||||
operation: WorkdirOperation::Attach,
|
||||
};
|
||||
|
||||
tool.execute(
|
||||
r#"{"workdir_id":"wd-attached"}"#,
|
||||
ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reopen_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,13 @@ use schemars::JsonSchema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
SystemPromptContributionSource, WorkspaceClient, WorkspaceClientError, WorkspaceRequest,
|
||||
WorkspaceRequestMethod,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -338,6 +343,160 @@ fn query_schema() -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
struct WorkspaceResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
async fn load(&self) -> Option<String> {
|
||||
match self
|
||||
.client
|
||||
.execute_memory_backend_operation(
|
||||
memory::backend::MemoryBackendOperation::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOperation::default(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(memory::backend::MemoryBackendOperationResult::ToolOutput(output)) => output.content,
|
||||
Ok(other) => {
|
||||
tracing::debug!(?other, "unexpected resident Memory Backend result");
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "resident Memory summary unavailable");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct MemoryFeatureInstallPlan {
|
||||
pub(crate) module: MemoryToolsFeature,
|
||||
pub(crate) resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
pub(crate) system_prompt_override: Option<String>,
|
||||
pub(crate) resolved_config: manifest::ResolvedMemoryFeatureConfig,
|
||||
}
|
||||
|
||||
impl MemoryFeatureInstallPlan {
|
||||
pub fn prepare(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
Self::prepare_resolved(
|
||||
manifest.feature.memory.clone(),
|
||||
client,
|
||||
prompts,
|
||||
manifest.profile.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_resolved(
|
||||
config: manifest::ResolvedMemoryFeatureConfig,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
|
||||
profile: Option<manifest::ProfileManifestSnapshot>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
let memory_consolidation_worker = profile.as_ref().is_some_and(|snapshot| {
|
||||
matches!(
|
||||
&snapshot.source,
|
||||
manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::Builtin,
|
||||
name,
|
||||
..
|
||||
} if name == "memory-consolidation"
|
||||
)
|
||||
});
|
||||
config
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
if !config.profile.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let workspace_id = client.workspace_id().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Memory tools require Backend Workspace API authority",
|
||||
)
|
||||
})?;
|
||||
let settings = config
|
||||
.workspace_settings()
|
||||
.expect("validated enabled Memory config has Workspace settings");
|
||||
if settings.workspace_id != workspace_id {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"Memory settings belong to {} instead of {}",
|
||||
settings.workspace_id, workspace_id
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let resident_summary_source = config.profile.resident.inject_summary.then(|| {
|
||||
Arc::new(WorkspaceResidentSummarySource {
|
||||
client: Arc::clone(&client),
|
||||
}) as Arc<dyn SystemPromptContributionSource>
|
||||
});
|
||||
let system_prompt_override = if memory_consolidation_worker {
|
||||
let language = settings.language;
|
||||
Some(
|
||||
prompts
|
||||
.memory_consolidation_system(&language)
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Some(Self {
|
||||
module: MemoryToolsFeature::new(client, config.profile.staging_tools),
|
||||
resident_summary_source,
|
||||
system_prompt_override,
|
||||
resolved_config: config,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MemoryToolsFeature {
|
||||
tools: Vec<ToolDefinition>,
|
||||
}
|
||||
|
||||
impl MemoryToolsFeature {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>, staging_tools: bool) -> Self {
|
||||
let tools = if staging_tools {
|
||||
workspace_http_memory_consolidation_tools(client)
|
||||
} else {
|
||||
workspace_http_memory_tools(client)
|
||||
};
|
||||
Self { tools }
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for MemoryToolsFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin("memory", "Memory")
|
||||
.with_description("Workspace Memory document, query, and staging tools.");
|
||||
for tool in &self.tools {
|
||||
let (meta, _) = tool();
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
for tool in &self.tools {
|
||||
let (meta, _) = tool();
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(meta.name, tool.clone()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -350,6 +509,39 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let content = content.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0_u8; 1024];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
let body = serde_json::json!({
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"kind": "tool_output",
|
||||
"summary": "resident Memory summary collected",
|
||||
"content": content,
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
});
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
format!("http://{addr}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||
let mut names = definitions
|
||||
.into_iter()
|
||||
@@ -368,6 +560,142 @@ mod tests {
|
||||
.input_schema
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_install_plan_is_the_fail_closed_config_boundary() {
|
||||
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
|
||||
let disabled = MemoryFeatureInstallPlan::prepare_resolved(
|
||||
manifest::ResolvedMemoryFeatureConfig::default(),
|
||||
test_client(),
|
||||
prompts.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(disabled.is_none());
|
||||
|
||||
let mut enabled = manifest::ResolvedMemoryFeatureConfig::default();
|
||||
enabled.profile.enabled = true;
|
||||
enabled.profile.resident.inject_summary = false;
|
||||
assert!(
|
||||
MemoryFeatureInstallPlan::prepare_resolved(
|
||||
enabled.clone(),
|
||||
test_client(),
|
||||
prompts.clone(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
enabled
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let mut foreign = enabled.clone();
|
||||
foreign.workspace_settings.as_mut().unwrap().workspace_id = "other-workspace".to_string();
|
||||
assert!(
|
||||
MemoryFeatureInstallPlan::prepare_resolved(
|
||||
foreign,
|
||||
test_client(),
|
||||
prompts.clone(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let plan = MemoryFeatureInstallPlan::prepare_resolved(
|
||||
enabled.clone(),
|
||||
test_client(),
|
||||
prompts.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(plan.resident_summary_source.is_none());
|
||||
assert!(plan.system_prompt_override.is_none());
|
||||
|
||||
enabled.profile.resident.inject_summary = true;
|
||||
let plan = MemoryFeatureInstallPlan::prepare_resolved(
|
||||
enabled,
|
||||
resident_client("# Durable Memory"),
|
||||
prompts,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.resident_summary_source
|
||||
.unwrap()
|
||||
.load()
|
||||
.await
|
||||
.as_deref(),
|
||||
Some("# Durable Memory")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_prompt_contribution_defers_resident_summary_until_loaded() {
|
||||
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
|
||||
let mut config = manifest::ResolvedMemoryFeatureConfig::default();
|
||||
config.profile.enabled = true;
|
||||
config
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let first = MemoryFeatureInstallPlan::prepare_resolved(
|
||||
config.clone(),
|
||||
resident_client("first resident summary"),
|
||||
prompts.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let restored = MemoryFeatureInstallPlan::prepare_resolved(
|
||||
config,
|
||||
resident_client("updated resident summary"),
|
||||
prompts,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
first
|
||||
.resident_summary_source
|
||||
.unwrap()
|
||||
.load()
|
||||
.await
|
||||
.as_deref(),
|
||||
Some("first resident summary")
|
||||
);
|
||||
assert_eq!(
|
||||
restored
|
||||
.resident_summary_source
|
||||
.unwrap()
|
||||
.load()
|
||||
.await
|
||||
.as_deref(),
|
||||
Some("updated resident summary")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_feature_owns_normal_and_staging_tool_surfaces() {
|
||||
let normal = MemoryToolsFeature::new(test_client(), false);
|
||||
let normal_names = tool_names(normal.tools);
|
||||
assert!(normal_names.contains(&"MemoryQuery".to_string()));
|
||||
assert!(!normal_names.contains(&"MemoryStagingList".to_string()));
|
||||
|
||||
let staging = MemoryToolsFeature::new(test_client(), true);
|
||||
assert_eq!(staging.descriptor().id.as_str(), "builtin:memory");
|
||||
let staging_names = tool_names(staging.tools);
|
||||
assert!(staging_names.contains(&"MemoryQuery".to_string()));
|
||||
assert!(staging_names.contains(&"MemoryStagingList".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_workspace_memory_tools_do_not_include_staging_tools() {
|
||||
let names = tool_names(workspace_http_memory_tools(test_client()));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+17
-19
@@ -28,7 +28,7 @@ const FINISH_DESCRIPTION: &str =
|
||||
"Finish Memory extraction after validating the number of candidates staged during this run.";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MemoryExtractState {
|
||||
pub(crate) struct MemoryStagingOutputState {
|
||||
view: Arc<SessionCapture>,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
source: SourceRef,
|
||||
@@ -37,7 +37,7 @@ pub(crate) struct MemoryExtractState {
|
||||
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
||||
}
|
||||
|
||||
impl MemoryExtractState {
|
||||
impl MemoryStagingOutputState {
|
||||
pub(crate) fn new(
|
||||
view: SessionCapture,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
@@ -70,22 +70,20 @@ impl MemoryExtractState {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MemoryExtractFeature {
|
||||
state: MemoryExtractState,
|
||||
pub(crate) struct MemoryStagingOutputFeature {
|
||||
state: MemoryStagingOutputState,
|
||||
}
|
||||
|
||||
impl MemoryExtractFeature {
|
||||
pub(crate) fn new(state: MemoryExtractState) -> Self {
|
||||
impl MemoryStagingOutputFeature {
|
||||
pub(crate) fn new(state: MemoryStagingOutputState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for MemoryExtractFeature {
|
||||
impl FeatureModule for MemoryStagingOutputFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("memory-extract", "Memory Extract")
|
||||
.with_description(
|
||||
"Memory staging and extraction completion, independent from session exploration.",
|
||||
)
|
||||
FeatureDescriptor::builtin("memory-staging-output", "Memory Staging Output")
|
||||
.with_description("Restricted Memory staging output for an extraction Internal Worker.")
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"StageMemoryCandidate",
|
||||
STAGE_DESCRIPTION,
|
||||
@@ -109,7 +107,7 @@ impl FeatureModule for MemoryExtractFeature {
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
|
||||
fn stage_definition(state: MemoryStagingOutputState) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
@@ -123,7 +121,7 @@ fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_definition(state: MemoryExtractState) -> ToolDefinition {
|
||||
fn finish_definition(state: MemoryStagingOutputState) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
@@ -157,7 +155,7 @@ struct FinishMemoryExtractionParams {
|
||||
}
|
||||
|
||||
struct StageMemoryCandidateTool {
|
||||
state: MemoryExtractState,
|
||||
state: MemoryStagingOutputState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -252,7 +250,7 @@ impl Tool for StageMemoryCandidateTool {
|
||||
}
|
||||
|
||||
struct FinishMemoryExtractionTool {
|
||||
state: MemoryExtractState,
|
||||
state: MemoryStagingOutputState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -431,8 +429,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn state() -> MemoryExtractState {
|
||||
MemoryExtractState::new(
|
||||
fn state() -> MemoryStagingOutputState {
|
||||
MemoryStagingOutputState::new(
|
||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||
crate::worker::marker_workspace_client(None, "test-backend"),
|
||||
SourceRef {
|
||||
@@ -445,8 +443,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn memory_extract_declares_only_memory_mutation_tools() {
|
||||
let descriptor = MemoryExtractFeature::new(state()).descriptor();
|
||||
assert_eq!(descriptor.id.as_str(), "builtin:memory-extract");
|
||||
let descriptor = MemoryStagingOutputFeature::new(state()).descriptor();
|
||||
assert_eq!(descriptor.id.as_str(), "builtin:memory-staging-output");
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user