158 Commits
Author SHA1 Message Date
Hare 016dbd7cb1 fix: preserve worker run generations across restore 2026-09-15 01:17:30 +09:00
Hare 7210d3c202 fix: reject failed worker restores before attach 2026-09-15 00:47:12 +09:00
Hare 572204b49a fix: resolve CLI connections before async runtime 2026-09-15 00:28:25 +09:00
Hare 86dd67a24c fix: skip stopped worker protocol subscriptions 2026-09-14 23:31:14 +09:00
Hare beeba1fdfc fix: migrate legacy worker snapshots 2026-09-14 22:28:01 +09:00
Hare 41b7b289d0 fix: serialize Workdir lease admission with writes 2026-09-14 20:32:21 +09:00
Hare 24237249d1 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 20:05:34 +09:00
Hare e448073b98 fix: fence resolved Workdir lease aliases 2026-09-14 20:05:27 +09:00
Hare c08152d069 fix: share workspace creation wire contract 2026-09-14 19:45:18 +09:00
Hare 3995561220 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-468-remove-local-workspace-authority 2026-09-14 19:26:44 +09:00
Hare aea51caeb4 fix: resolve workspaces through backend repositories 2026-09-14 19:24:44 +09:00
Hare 3eca380bd8 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 19:12:17 +09:00
Hare 8a3e06bc81 feat: add selective Workdir symlink policies 2026-09-14 19:09:12 +09:00
Hare c4274c42cb fix: align plugin help with offline commands 2026-09-14 18:52:00 +09:00
Hare a61ad15767 refactor: remove ambient plugin authority 2026-09-14 18:40:42 +09:00
Hare 7f1e374fd7 fix: remove repository-local workspace selection 2026-09-14 18:31:04 +09:00
Hare e3f5445a02 fix: move workspace metadata authority to server database 2026-09-14 18:31:04 +09:00
Hare d2cb50d081 fix: fence runtime removal races and retries 2026-09-13 02:54:01 +09:00
Hare 6c609808c9 fix: use authoritative runtime removal result 2026-09-13 02:30:24 +09:00
Hare 2d4c7b383a feat: add guarded runtime removal operation 2026-09-13 02:30:15 +09:00
Hare c21ed7dff2 fix: preserve credential candidates across Workdir retries 2026-09-13 01:24:26 +09:00
Hare 448e392a0e fix: persist Workdir credential candidate snapshots 2026-09-13 01:14:39 +09:00
Hare d97c40d6af fix: reject plain HTTP repository sources 2026-09-13 01:12:01 +09:00
Hare 2d512b6be6 fix: remove stale HTTP repository access branch 2026-09-13 01:04:46 +09:00
Hare f061a95b48 fix: validate repository URI before source kind 2026-09-13 00:45:43 +09:00
Hare eefdef1bef fix: lock verified runtime public keys 2026-09-13 00:32:44 +09:00
Hare 7675f81999 fix: reject plain HTTP repository sources 2026-09-13 00:29:16 +09:00
Hare 8fb592071f fix: fence stale repository SSH probes 2026-09-13 00:27:32 +09:00
Hare 2528312142 fix: lock verified Runtime public keys in settings 2026-09-13 00:23:25 +09:00
Hare 08d7965ea8 fix: centralize repository access expiry 2026-09-13 00:20:25 +09:00
Hare e0badad91f fix: invalidate stale repository SSH probes 2026-09-13 00:17:12 +09:00
Hare f0a91ce2d8 fix: centralize repository access expiry 2026-09-13 00:11:03 +09:00
Hare 24cab83f48 test: cover repository credential rotate visibility 2026-09-13 00:05:14 +09:00
Hare 33a2b5d702 fix: unify workspace request routing 2026-09-12 23:49:13 +09:00
Hare 7f807004ad Update AGENTS.md 2026-09-12 22:47:35 +09:00
Hare 5564425488 fix: retry retained workdir removal 2026-09-12 13:30:25 +09:00
Hare 4a89c04732 fix: allow corrupted workdir removal 2026-09-12 01:43:37 +09:00
Hare ec5a403ec6 feat: replace linked worktrees with runtime clones 2026-09-12 01:43:21 +09:00
Hare f6ce1df766 feat: support workspace-managed SSH repository access 2026-09-11 22:55:06 +09:00
Hare 9d7ddcc04a fix: enforce Workspace Runtime binding lifecycle 2026-09-09 13:20:00 +09:00
Hare 3df611636b feat: complete Workspace Runtime management flow 2026-09-09 09:38:26 +09:00
Hare d0999326bd fix: use Backend audience for embedded Runtime requests 2026-09-09 09:38:26 +09:00
Hare 6fbc65476c fix: migrate standalone Worker manifest snapshots 2026-09-09 01:22:59 +09:00
Hare fcc7d79d80 feat: authorize scoped symlink paths lexically 2026-09-09 00:51:21 +09:00
Hare 18fd6a1f5e fix: restore remote Runtime management contracts 2026-09-09 00:26:04 +09:00
Hare a072562034 chore: merge develop into hare/develop 2026-09-08 13:04:10 +09:00
Hare 2b4a2bc688 fix: fail closed on missing workspace capability 2026-09-08 12:40:04 +09:00
Hare 3344d9f8b2 refactor: remove server-global runtime trust 2026-09-08 12:19:39 +09:00
Hare fae36d220d fix: complete Runtime verification cutover 2026-09-08 10:11:18 +09:00
Hare 7b6a84a550 feat: project Runtime verification state 2026-09-08 09:22:44 +09:00
Hare f29c343879 feat: verify Workspace-signed Runtime bindings 2026-09-08 08:13:58 +09:00
Hare f5e9f49a13 fix: bind Runtime WebSockets to egress policy 2026-09-08 05:29:53 +09:00
Hare 73a35599d2 fix: complete configured Runtime onboarding 2026-09-08 05:15:14 +09:00
Hare 5080d7860e fix: preserve Runtime binding trust boundaries 2026-09-08 04:54:23 +09:00
Hare 7fb1d4056c feat: add manual Runtime trust setup UI 2026-09-08 04:22:45 +09:00
Hare 243a081874 feat: add configured Workspace Runtime bindings 2026-09-08 04:22:32 +09:00
Hare 04924cf796 fix: bound Runtime issuer trust surfaces 2026-09-08 02:31:58 +09:00
Hare 8f0917b8bc docs: remove obsolete local profile override guidance 2026-09-08 02:13:26 +09:00
Hare d4ad46127a feat: add GPT-6 Astra model catalog entry 2026-09-08 02:13:26 +09:00
Hare fba5ecf54c fix: harden Workspace issuer bootstrap 2026-09-08 02:00:19 +09:00
Hare e035df9e7b feat: add Runtime Workspace issuer trust 2026-09-08 01:54:10 +09:00
Hare 3baf0b6358 feat: integrate Workspace signing identity authority 2026-09-08 00:48:45 +09:00
Hare 4de04e42b5 fix: harden identity publication recovery 2026-09-08 00:30:38 +09:00
Hare ebec98a14c feat: add Workspace signing identity authority 2026-09-07 23:52:20 +09:00
Hare f966470d33 fix: acknowledge durable submissions before run preparation 2026-09-07 21:38:19 +09:00
Hare c76ede2ab4 fix: allow slow initial input commits 2026-09-07 20:21:22 +09:00
Hare 2fd043b634 fix: expand worker creation timeout budgets 2026-09-07 19:43:26 +09:00
Hare fb6bbe9145 fix: wait for worker shutdown before deletion 2026-09-07 19:31:32 +09:00
Hare d1585d7483 fix: route worker failure logs through tracing 2026-09-07 18:14:07 +09:00
Hare 13d853217d fix: log failed worker creations to stdout 2026-09-07 04:56:54 +09:00
Hare cc27d57e4a fix: reclaim failed worker creations 2026-09-07 04:44:19 +09:00
Hare f5c5ea5a0b fix: route worker consoles through logical resources 2026-09-07 03:53:34 +09:00
Hare f1baea1705 feat: distribute latest workspace config to runtimes 2026-09-07 02:40:03 +09:00
Hare 31f7d39647 feat: add Server database migration command 2026-09-06 16:17:48 +09:00
Hare c5a834bfd2 fix: restore composable Workspace schema migrations 2026-09-06 16:17:40 +09:00
Hare 1eef9b75ee Merge branch 'develop' into hare/develop 2026-09-06 13:39:37 +09:00
Hare ba9c885f52 feat: integrate Worker state authority 2026-09-06 11:00:00 +09:00
Hare 88683a8d8f test: provide authoritative websocket snapshot 2026-09-06 10:41:20 +09:00
Hare 0c48c5dee3 fix: fail closed without worker snapshots 2026-09-06 10:33:45 +09:00
Hare 101a0acb6b fix: terminalize removed worker reservations 2026-09-06 10:32:45 +09:00
Hare 2d1956b653 fix: separate worker state from runtime lifecycle 2026-09-06 10:05:54 +09:00
Hare b7bba8b53a fix: preserve workspace deletion checkpoints 2026-09-06 10:02:18 +09:00
Hare 7b25b767f8 fix: checkpoint workspace cleanup children 2026-09-06 09:35:44 +09:00
Hare 4631b95144 fix: fence workspace deletion side effects 2026-09-06 09:21:37 +09:00
Hare 38627c498b fix: harden workspace deletion recovery 2026-09-06 08:57:54 +09:00
Hare 282a8d31b5 fix: enforce monotonic worker state projection 2026-09-06 07:56:25 +09:00
Hare ab4fb4c1ee fix: enforce workspace deletion blockers 2026-09-06 07:54:52 +09:00
Hare e3e9e83bc1 feat: add guarded workspace deletion workflow 2026-09-06 07:22:33 +09:00
Hare 4269ebec04 chore: merge develop into worker state snapshot 2026-09-06 07:17:40 +09:00
Hare e8b9adcde4 feat: add revisioned worker execution state 2026-09-06 06:54:29 +09:00
Hare 668a9062b3 fix: fence runtime detail route state 2026-09-06 06:11:23 +09:00
Hare 5fd2ccf084 fix: gate runtime key reveal and revoke confirmation 2026-09-06 05:50:29 +09:00
Hare 5686bbc9fd fix: preview runtime trust rotation fingerprint 2026-09-06 05:25:03 +09:00
Hare 2cd57a32b2 fix: align runtime trust schema and built-in controls 2026-09-06 05:17:27 +09:00
Hare 89f4f99622 Merge branch 'develop' into work/T-595-runtime-trust-key-management
# Conflicts:
#	web/workspace/deno.json
2026-09-06 05:07:26 +09:00
Hare 78d571ed14 feat: add workspace runtime trust key management 2026-09-06 05:06:37 +09:00
Hare e5332f4a7f feat: route SubWorker workdir execution through parent 2026-09-06 04:28:52 +09:00
Hare 3ed1545c3c feat: integrate worker submit queue 2026-09-06 04:24:07 +09:00
Hare 9da20d15da fix: compose passive notify with queued submit 2026-09-06 04:15:30 +09:00
Hare 052d60bd7d fix: fence late command events and spawn rollback 2026-09-06 04:10:23 +09:00
Hare 2456d6fda5 fix: preserve pending activation order on restore 2026-09-06 04:00:02 +09:00
Hare e7803d1aba fix: qualify runtime binding helper 2026-09-06 03:52:42 +09:00
Hare ca5fddf89b fix: fence recursive SubWorker shutdown 2026-09-06 03:48:54 +09:00
Hare 88fad3893e Merge commit '5e9f7a7dc3d1169118412376f16b72850aab63f2' into work/T-593-workspace-runtime-bindings
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-09-06 03:46:09 +09:00
Hare 82f9b0e48c chore: merge current develop into T-588 2026-09-06 03:45:30 +09:00
Hare ddb4c1454d fix: enable running composer delivery 2026-09-06 03:32:23 +09:00
Hare 7b1cf854f2 fix: serialize scoped command teardown 2026-09-06 03:29:50 +09:00
Hare 51c6d7f835 fix: require authenticated runtime bindings 2026-09-06 03:28:19 +09:00
Hare 9d55ce0a87 fix: reconcile interrupted upload pins 2026-09-06 03:15:44 +09:00
Hare 0f8d61188a fix: order SubWorker cleanup before Workdir release 2026-09-06 03:12:55 +09:00
Hare 7363dffb9d fix: gate runtime projection broadcasts 2026-09-06 03:00:36 +09:00
Hare 8e4b7deaa4 fix: preserve authenticated submit source 2026-09-06 02:56:51 +09:00
Hare ec845cbc25 fix: fence revoked runtime bindings 2026-09-06 02:52:15 +09:00
Hare e7079e223f fix: close scoped SubWorker command authority 2026-09-06 02:37:13 +09:00
Hare dea5bd581d fix: secure submit activation handoff 2026-09-06 02:31:10 +09:00
Hare 75b85b46d1 feat: scope runtime bindings by workspace 2026-09-06 02:30:13 +09:00
Hare 68f00bc948 refactor: broker SubWorker Workdir tools through parent 2026-09-06 02:17:44 +09:00
Hare 5e9f7a7dc3 feat: add versioned runtime connection ping 2026-09-06 02:17:26 +09:00
Hare b038f022d3 fix: harden submit queue durability 2026-09-06 01:35:26 +09:00
Hare cf7515fb35 fix: keep tool overview neutral on failures 2026-09-06 01:20:39 +09:00
Hare bb4c1dfe4f docs: update flow submit protocol example 2026-09-05 23:25:25 +09:00
Hare 72b56964c3 chore: refresh T-588 onto develop 2026-09-05 23:19:52 +09:00
Hare 5b0a6691f8 fix: fence pending queue controls 2026-09-05 23:16:19 +09:00
Hare 1239c638a5 Merge branch 'develop' into hare/develop 2026-09-05 22:39:57 +09:00
Hare d2fa0787d8 fix: retain child-local bash output scope 2026-09-05 22:29:19 +09:00
Hare a7056702e8 fix: keep worker-host paths out of remote delegations 2026-09-05 22:18:46 +09:00
Hare bb56283063 feat: persist worker submit activation queue 2026-09-05 22:06:59 +09:00
Hare 130ef1f0fe fix: align Backend worker picker columns 2026-09-05 21:19:18 +09:00
Hare 724205b1df feat: create Backend workers from bare TUI launch 2026-09-05 21:19:12 +09:00
Hare 69824ea45d fix: defer resident Memory loading until first run 2026-09-05 07:52:47 +09:00
Hare 15bc299987 fix: align integrated validation contracts 2026-09-05 06:49:06 +09:00
Hare 87bdb0c6ed chore: integrate develop into hare/develop candidate 2026-09-05 04:15:47 +09:00
Hare aa96bbedbc fix: harden Memory restore and scheduling 2026-09-05 00:24:00 +09:00
Hare 4df277c81f fix: preserve Memory consolidation enablement 2026-09-04 23:53:59 +09:00
Hare 12646b6ca0 refactor: install Memory prompt contributions through Feature 2026-09-04 23:40:12 +09:00
Hare 1e674d70c2 refactor: unify Memory feature configuration authority 2026-09-04 22:55:37 +09:00
Hare 5ee77698db fix: gate workspace memory lifecycle authority 2026-09-04 20:53:27 +09:00
Hare d1f5661881 test: cover memory lifecycle outcomes 2026-09-04 20:11:29 +09:00
Hare 532d078720 test: exercise memory lifecycle background behavior 2026-09-04 19:41:04 +09:00
Hare 27e5df106f fix: complete memory lifecycle feature boundaries 2026-09-04 19:16:01 +09:00
Hare 33d98868c3 refactor: move memory extraction into lifecycle feature 2026-09-04 18:44:50 +09:00
Hare fb13e53cb5 test: cover start race at rewrite barrier 2026-09-04 16:53:35 +09:00
Hare 60a5495ccd fix: hold feature task barrier across rewrites 2026-09-04 16:34:15 +09:00
Hare f1dc90621c fix: fence feature task shutdown races 2026-09-04 16:13:35 +09:00
Hare eecb116709 fix: bound feature lifecycle execution 2026-09-04 16:04:10 +09:00
Hare 783d25b1c4 fix: project ticket blockers from current DTO shape 2026-09-04 15:47:11 +09:00
Hare af06eecfd0 feat: add executable feature lifecycle hooks 2026-09-04 14:50:44 +09:00
Hare 9bd08a3a5b fix: bound Skill API response handling 2026-09-04 13:55:13 +09:00
Hare 3d66247e11 test: include Skill API contract suite 2026-09-04 13:36:36 +09:00
Hare 4390554477 feat: share Skill REST DTO authority 2026-09-04 13:30:39 +09:00
Hare 74457db4eb Merge commit '89856eb7c3dd6b659ff952d7e3be4a8c0885bf98' into work/T-584-agen-typed-interceptor 2026-09-04 13:01:55 +09:00
Hare 5d61da481b fix: consume internal tool batch cancellation 2026-09-04 13:01:37 +09:00
Hare 42d109cae3 Merge commit '4ca8ea1694d88db501c48dd4947fdbd6f2828136' into work/T-584-agen-typed-interceptor 2026-09-04 12:28:30 +09:00
Hare 9b48b1ff5d fix: terminalize parallel tool siblings on interceptor stop 2026-09-04 12:27:59 +09:00
Hare eac4a0c071 fix: harden interceptor lifecycle contracts 2026-09-03 16:46:02 +09:00
Hare e62c7cf4f5 Merge commit '7abc6aca45a82665d33115612148536f3a5dd275' into work/T-584-agen-typed-interceptor 2026-09-03 15:47:21 +09:00
Hare 0245980ea5 refactor: finalize interceptor lifecycle boundaries 2026-09-03 15:33:15 +09:00
Hare 68b1aa64e9 feat: type async interceptor failures 2026-09-03 14:46:07 +09:00
258 changed files with 56212 additions and 28052 deletions
+7 -9
View File
@@ -1,21 +1,19 @@
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。 すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
## このシステムに置ける設計要旨 ## このシステムに置ける設計要旨
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。 - プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。 - 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
### LLM コンテキストの加工原則 ### LLM コンテキストの加工原則
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止** LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等) 一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。 これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。 過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
--- ---
Generated
+50 -654
View File
@@ -2,15 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "addr2line"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -177,12 +168,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.1" version = "1.9.1"
@@ -476,9 +461,6 @@ name = "bumpalo"
version = "3.20.2" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
dependencies = [
"allocator-api2",
]
[[package]] [[package]]
name = "bytecount" name = "bytecount"
@@ -662,15 +644,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -765,15 +738,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpp_demangle"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -792,148 +756,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cranelift-assembler-x64"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7"
dependencies = [
"cranelift-entity",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-bitset"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69"
dependencies = [
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
"cranelift-codegen-shared",
"cranelift-control",
"cranelift-entity",
"cranelift-isle",
"gimli",
"hashbrown 0.17.1",
"libm",
"log",
"pulley-interpreter",
"regalloc2",
"rustc-hash",
"serde",
"smallvec",
"target-lexicon",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
"cranelift-srcgen",
"heck",
"pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518"
[[package]]
name = "cranelift-control"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b"
dependencies = [
"cranelift-bitset",
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-frontend"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a"
dependencies = [
"cranelift-codegen",
"log",
"smallvec",
"target-lexicon",
]
[[package]]
name = "cranelift-isle"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087"
[[package]]
name = "cranelift-native"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc"
dependencies = [
"cranelift-codegen",
"libc",
"target-lexicon",
]
[[package]]
name = "cranelift-srcgen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -1374,18 +1196,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -1788,18 +1598,6 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "gimli"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"fnv",
"hashbrown 0.16.1",
"indexmap",
"stable_deref_trait",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.3" version = "0.3.3"
@@ -1922,11 +1720,6 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
@@ -2614,15 +2407,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "manifest" name = "manifest"
version = "0.1.0" version = "0.1.0"
@@ -2713,15 +2497,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memfd"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227"
dependencies = [
"rustix 1.1.4",
]
[[package]] [[package]]
name = "memmap2" name = "memmap2"
version = "0.9.10" version = "0.9.10"
@@ -3003,18 +2778,6 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"crc32fast",
"hashbrown 0.17.1",
"indexmap",
"memchr",
]
[[package]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.7.1" version = "0.7.1"
@@ -3410,18 +3173,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]] [[package]]
name = "postscript" name = "postscript"
version = "0.14.1" version = "0.14.1"
@@ -3524,29 +3275,6 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "pulley-interpreter"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f"
dependencies = [
"cranelift-bitset",
"log",
"pulley-macros",
"wasmtime-internal-core",
]
[[package]]
name = "pulley-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -3824,20 +3552,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "regalloc2"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186"
dependencies = [
"allocator-api2",
"bumpalo",
"hashbrown 0.17.1",
"log",
"rustc-hash",
"smallvec",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.3"
@@ -3971,12 +3685,6 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -4216,10 +3924,6 @@ name = "semver"
version = "1.0.27" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde" name = "serde"
@@ -4536,9 +4240,6 @@ name = "smallvec"
version = "1.15.1" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -4771,12 +4472,6 @@ dependencies = [
"xattr", "xattr",
] ]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "target-triple" name = "target-triple"
version = "1.0.0" version = "1.0.0"
@@ -5086,8 +4781,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"rustls",
"rustls-pki-types",
"tokio", "tokio",
"tokio-rustls",
"tungstenite 0.29.0", "tungstenite 0.29.0",
"webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -5257,6 +4956,16 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.23" version = "0.3.23"
@@ -5267,12 +4976,15 @@ dependencies = [
"nu-ansi-term", "nu-ansi-term",
"once_cell", "once_cell",
"regex-automata", "regex-automata",
"serde",
"serde_json",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing", "tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
"tracing-serde",
] ]
[[package]] [[package]]
@@ -5382,6 +5094,8 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"rand 0.9.4", "rand 0.9.4",
"rustls",
"rustls-pki-types",
"sha1", "sha1",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -5683,27 +5397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [ dependencies = [
"leb128fmt", "leb128fmt",
"wasmparser 0.244.0", "wasmparser",
]
[[package]]
name = "wasm-encoder"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6"
dependencies = [
"leb128fmt",
"wasmparser 0.248.0",
]
[[package]]
name = "wasm-encoder"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f"
dependencies = [
"leb128fmt",
"wasmparser 0.252.0",
] ]
[[package]] [[package]]
@@ -5714,8 +5408,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap", "indexmap",
"wasm-encoder 0.244.0", "wasm-encoder",
"wasmparser 0.244.0", "wasmparser",
] ]
[[package]] [[package]]
@@ -5743,286 +5437,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "wasmparser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.17.1",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "wasmparser"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c"
dependencies = [
"bitflags 2.11.0",
"indexmap",
"semver",
]
[[package]]
name = "wasmprinter"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183"
dependencies = [
"anyhow",
"termcolor",
"wasmparser 0.248.0",
]
[[package]]
name = "wasmtime"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527"
dependencies = [
"addr2line",
"async-trait",
"bitflags 2.11.0",
"bumpalo",
"cc",
"cfg-if",
"encoding_rs",
"libc",
"log",
"mach2",
"memfd",
"object",
"once_cell",
"postcard",
"pulley-interpreter",
"rustix 1.1.4",
"semver",
"serde",
"serde_derive",
"smallvec",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-component-macro",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
"wasmtime-internal-fiber",
"wasmtime-internal-jit-debug",
"wasmtime-internal-jit-icache-coherence",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
"wasmtime-internal-winch",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-environ"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71"
dependencies = [
"anyhow",
"cpp_demangle",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-entity",
"gimli",
"hashbrown 0.17.1",
"indexmap",
"log",
"object",
"postcard",
"rustc-demangle",
"semver",
"serde",
"serde_derive",
"sha2 0.10.9",
"smallvec",
"target-lexicon",
"wasm-encoder 0.248.0",
"wasmparser 0.248.0",
"wasmprinter",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
]
[[package]]
name = "wasmtime-internal-component-macro"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96c17f35fae2ab574667aba0c58fd56349a6f788ac42541a2e543116d5cfb91"
dependencies = [
"anyhow",
"proc-macro2",
"quote",
"syn 2.0.117",
"wasmtime-internal-component-util",
"wasmtime-internal-wit-bindgen",
"wit-parser 0.248.0",
]
[[package]]
name = "wasmtime-internal-component-util"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2eeb9b53222859e6f5dc73d2ccfb33254d672469cac11b693a71912e2f3817"
[[package]]
name = "wasmtime-internal-core"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c"
dependencies = [
"hashbrown 0.17.1",
"libm",
"serde",
]
[[package]]
name = "wasmtime-internal-cranelift"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b"
dependencies = [
"cfg-if",
"cranelift-codegen",
"cranelift-control",
"cranelift-entity",
"cranelift-frontend",
"cranelift-native",
"gimli",
"itertools",
"log",
"object",
"pulley-interpreter",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-fiber"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a"
dependencies = [
"cc",
"cfg-if",
"libc",
"rustix 1.1.4",
"wasmtime-environ",
"wasmtime-internal-versioned-export-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-jit-debug"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551"
dependencies = [
"cc",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6"
dependencies = [
"cfg-if",
"libc",
"wasmtime-internal-core",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-unwinder"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5"
dependencies = [
"cfg-if",
"cranelift-codegen",
"log",
"object",
"wasmtime-environ",
]
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "wasmtime-internal-winch"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "828b66175c54a0d00b4c1c1c76658d8aa73aeb9fa3553575c5eee56d40f2eb18"
dependencies = [
"cranelift-codegen",
"gimli",
"log",
"object",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-cranelift",
"winch-codegen",
]
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae00896ad9bef1b3ca6401ae9a841daa6f357dd91541b6baf87082946d1bde1"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"heck",
"indexmap",
"wit-parser 0.248.0",
]
[[package]]
name = "wast"
version = "252.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder 0.252.0",
]
[[package]]
name = "wat"
version = "1.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb"
dependencies = [
"wast",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.94" version = "0.3.94"
@@ -6120,6 +5534,24 @@ dependencies = [
"rustls-pki-types", "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]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
@@ -6229,25 +5661,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89c09acfdfa281b3340e1e94ef3cf6618d69eab975280f881e154c29f49419c1"
dependencies = [
"cranelift-assembler-x64",
"cranelift-codegen",
"gimli",
"regalloc2",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -6511,7 +5924,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck",
"wit-parser 0.244.0", "wit-parser",
] ]
[[package]] [[package]]
@@ -6558,10 +5971,10 @@ dependencies = [
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"wasm-encoder 0.244.0", "wasm-encoder",
"wasm-metadata", "wasm-metadata",
"wasmparser 0.244.0", "wasmparser",
"wit-parser 0.244.0", "wit-parser",
] ]
[[package]] [[package]]
@@ -6579,26 +5992,7 @@ dependencies = [
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"unicode-xid", "unicode-xid",
"wasmparser 0.244.0", "wasmparser",
]
[[package]]
name = "wit-parser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856"
dependencies = [
"anyhow",
"hashbrown 0.17.1",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser 0.248.0",
] ]
[[package]] [[package]]
@@ -6650,6 +6044,7 @@ dependencies = [
"serial_test", "serial_test",
"session-metrics", "session-metrics",
"session-store", "session-store",
"sha2 0.11.0",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
@@ -6660,11 +6055,8 @@ dependencies = [
"tracing", "tracing",
"tungstenite 0.28.0", "tungstenite 0.28.0",
"uuid", "uuid",
"wasmtime",
"wat",
"workdir", "workdir",
"workspace-api", "workspace-api",
"yoi-plugin-pdk",
] ]
[[package]] [[package]]
@@ -6694,6 +6086,8 @@ dependencies = [
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"toml", "toml",
"tower", "tower",
"tracing",
"tracing-subscriber",
"url", "url",
"uuid", "uuid",
"workdir", "workdir",
@@ -6782,6 +6176,7 @@ dependencies = [
"tokio", "tokio",
"toml", "toml",
"tui", "tui",
"uuid",
"worker", "worker",
"workspace-api", "workspace-api",
] ]
@@ -6839,6 +6234,7 @@ dependencies = [
"toml", "toml",
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber",
"ts-rs", "ts-rs",
"url", "url",
"uuid", "uuid",
+1
View File
@@ -132,6 +132,7 @@ tokio-tungstenite = "0.29"
tower = "0.5" tower = "0.5"
toml = "1.1" toml = "1.1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
url = "2.5" url = "2.5"
uuid = "1.23" uuid = "1.23"
zeroize = "1" zeroize = "1"
+6 -3
View File
@@ -40,7 +40,7 @@ use tracing_subscriber::EnvFilter;
use agen::{ use agen::{
Engine, EngineRunExit, RunInterruptionReason, Engine, EngineRunExit, RunInterruptionReason,
interceptor::{Interceptor, PostToolAction, ToolResultInfo}, interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
llm_client::{ llm_client::{
LlmClient, LlmClient,
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport}, capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
#[async_trait] #[async_trait]
impl Interceptor for ToolResultPrinterPolicy { 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 let name = self
.call_names .call_names
.lock() .lock()
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
println!(" Result ({}): ✅ {}", name, info.result.summary); println!(" Result ({}): ✅ {}", name, info.result.summary);
} }
PostToolAction::Continue Ok(PostToolAction::Continue)
} }
} }
+321 -91
View File
@@ -15,8 +15,12 @@ use crate::{
}, },
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind}, handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
interceptor::{ interceptor::{
DefaultInterceptor, Interceptor, PostToolAction, PreRequestAction, PreToolAction, AssistantTurnEndContext, DefaultInterceptor, Interceptor, InterceptorCallId,
PromptAction, ToolCallInfo, ToolResultInfo, TurnEndAction, InterceptorCounter, InterceptorCounters, InterceptorError, InterceptorErrorCategory,
InterceptorFailure, InterceptorInvocation, InterceptorPhase, InterceptorRunId,
InterceptorTurnId, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, RunExitContext,
ToolCallInfo, ToolResultInfo, TurnEndAction,
}, },
llm_client::{ llm_client::{
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream, ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream,
@@ -58,6 +62,9 @@ pub enum EngineError {
/// A durable-history observer rejected an item before it entered history. /// A durable-history observer rejected an item before it entered history.
#[error("History append failed: {0}")] #[error("History append failed: {0}")]
HistoryAppend(String), HistoryAppend(String),
/// A trusted host interceptor callback failed.
#[error(transparent)]
Interceptor(#[from] InterceptorFailure),
/// Tool terminalization lost its execution-attempt compare-and-set fence. /// Tool terminalization lost its execution-attempt compare-and-set fence.
#[error("Tool execution attempt fence failed: {0}")] #[error("Tool execution attempt fence failed: {0}")]
ToolAttemptFence(String), ToolAttemptFence(String),
@@ -181,7 +188,7 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
/// Result of [`Engine::run`] or [`Engine::resume`]. /// Result of [`Engine::run`] or [`Engine::resume`].
/// ///
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome. /// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
pub struct EngineRunOutput<C: LlmClient, A = ()> { pub struct EngineRunOutput<C: LlmClient, A: Send + Sync = ()> {
/// The Engine, now in Locked state. /// The Engine, now in Locked state.
pub engine: Engine<C, Locked, A>, pub engine: Engine<C, Locked, A>,
/// Outcome of the turn. /// Outcome of the turn.
@@ -305,7 +312,7 @@ enum StreamCompletion {
Interrupted { reason: String }, 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 /// LLM client
client: C, client: C,
/// Retry policy for opening an LLM response stream. /// 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 handle
tool_server: ToolServerHandle, tool_server: ToolServerHandle,
/// Interceptor for control-flow decisions /// Interceptor for control-flow decisions
interceptor: Box<dyn Interceptor>, interceptor: Box<dyn Interceptor<A>>,
/// System prompt /// System prompt
system_prompt: Option<String>, system_prompt: Option<String>,
/// History length at lock time (only meaningful in Locked state) /// 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 /// `max_turns` is enforced against this run-scoped count rather than the
/// cumulative `turn_count` above. /// cumulative `turn_count` above.
active_run_turn_count: Option<usize>, 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 /// LlmCall count (per-Engine running counter, monotonic). Unlike
/// `turn_count` this never collapses retries. /// `turn_count` this never collapses retries.
llm_call_count: usize, llm_call_count: usize,
@@ -421,21 +433,57 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
_state: PhantomData<(S, 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) { fn start_logical_run(&mut self) {
self.active_run_turn_count = Some(0); 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) { fn ensure_logical_run(&mut self) {
self.active_run_turn_count.get_or_insert(0); 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>) { fn interceptor_invocation(
if !matches!( &mut self,
result, phase: InterceptorPhase,
Ok(EngineResult::Paused | EngineResult::Yielded) | Err(EngineError::PauseRequested) 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_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 /// The interceptor governs approval, skip, pause, and abort decisions
/// at key points in the execution loop. If not set, the default /// at key points in the execution loop. If not set, the default
/// interceptor is used (all Continue / Finish). /// 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); 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 /// `Some` is retained only while Pause or Yield permits a later
/// [`resume`](Self::resume). Terminal outcomes return this to `None`. /// [`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> { pub fn active_run_turn_count(&self) -> Option<usize> {
self.active_run_turn_count 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. /// [`resume`](Self::resume) starts a fresh budget.
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) { pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
self.active_run_turn_count = turn_count; 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 /// 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 request
} }
/// Hooks: on_prompt_submit async fn finalize_run_exit(
///
async fn finalize_interruption<T>(
&mut self, &mut self,
result: Result<T, EngineError>, history: &History<A>,
) -> Result<T, EngineError> { result: Result<EngineResult, EngineError>,
match result { ) -> EngineRunExit {
Ok(value) => Ok(value), let exit = EngineRunExit::from(result);
Err(err) => { let invocation = self.interceptor_invocation(InterceptorPhase::RunExit, None, None, 0);
let reason = match &err { self.last_run_exit_observer_failure = None;
EngineError::Aborted(reason) => reason.clone(), if let Err(error) = self
EngineError::Cancelled => "Cancelled".to_string(), .interceptor
_ => err.to_string(), .on_run_exit(RunExitContext {
}; invocation,
self.interceptor.on_abort(&reason).await; exit: &exit,
Err(err) 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) /// 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) // Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new(); let mut approved_calls = Vec::new();
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() { 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); let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) { 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 { let mut info = ToolCallInfo {
invocation,
history: history.entries(),
call: tool_call.clone(), call: tool_call.clone(),
meta, meta,
tool, tool,
context, 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::Continue => {}
PreToolAction::Skip => { PreToolAction::Skip => {
continue; continue;
} }
PreToolAction::SyntheticResult(result) => { 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 tool_call = info.call;
let mut context = info.context; let mut context = info.context;
context.call_id = tool_call.id.clone(); context.call_id = tool_call.id.clone();
@@ -1287,8 +1389,11 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let mut terminal_call_ids = HashSet::new(); let mut terminal_call_ids = HashSet::new();
let mut pause_requested = false; let mut pause_requested = false;
let mut pause_deadline = None; let mut pause_deadline = None;
let mut batch_error = None;
let mut locally_enqueued_cancel = false;
for result in synthetic_results { for result in synthetic_results {
self.finalize_and_commit_tool_result( if let Err(error) = self
.finalize_and_commit_tool_result(
history, history,
annotate, annotate,
result, result,
@@ -1297,10 +1402,18 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&mut attempt_fence, &mut attempt_fence,
&mut terminal_call_ids, &mut terminal_call_ids,
) )
.await?; .await
&& batch_error.is_none()
{
batch_error = Some(error);
}
} }
let mut futures = futures; 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() { while !futures.is_empty() {
tokio::select! { tokio::select! {
// If cancellation and a completed result are both ready, drain // 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() => { result = futures.next() => {
let (attempt_id, result) = let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a 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, history,
annotate, annotate,
result, result,
@@ -1318,7 +1431,15 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map, &call_info_map,
&mut attempt_fence, &mut attempt_fence,
&mut terminal_call_ids, &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 => { pause = self.pause_rx.recv(), if !pause_requested => {
if pause.is_some() { 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() => { _ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
pause_deadline = None; pause_deadline = None;
let _ = self.cancel_tx.try_send(()); let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
} }
cancel = self.cancel_rx.recv() => { cancel = self.cancel_rx.recv() => {
if cancel.is_some() { if cancel.is_some() {
@@ -1380,7 +1502,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
result = futures.next() => { result = futures.next() => {
let (attempt_id, result) = let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a 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, history,
annotate, annotate,
result, result,
@@ -1388,7 +1510,11 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map, &call_info_map,
&mut attempt_fence, &mut attempt_fence,
&mut terminal_call_ids, &mut terminal_call_ids,
).await?; ).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
} }
_ = tokio::time::sleep_until(deadline) => break, _ = 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) { if let Some(handle) = execution_handles.get(call_id) {
handle.force_close(); handle.force_close();
} }
self.finalize_and_commit_tool_result( if let Err(error) = self.finalize_and_commit_tool_result(
history, history,
annotate, annotate,
ToolResult::outcome_unknown(call_id), ToolResult::outcome_unknown(call_id),
@@ -1410,11 +1536,18 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map, &call_info_map,
&mut attempt_fence, &mut attempt_fence,
&mut terminal_call_ids, &mut terminal_call_ids,
).await?; ).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
} }
} }
self.timeline.abort_current_block(); self.timeline.abort_current_block();
if let Some(error) = batch_error.take() {
return Err(error);
}
if pause_requested { if pause_requested {
return Ok(ToolExecutionResult::Paused); 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 { Ok(if pause_requested {
ToolExecutionResult::Paused ToolExecutionResult::Paused
} else { } 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 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() { if tool_result.is_error && tool_result.disposition.is_success() {
tool_result.disposition = ToolResultDisposition::Error; tool_result.disposition = ToolResultDisposition::Error;
} }
tool_result.is_error = !tool_result.disposition.is_success(); tool_result.is_error = !tool_result.disposition.is_success();
// Cap content only after post_tool_call so interceptors still observe // Bound the terminal payload before committing it so the post-tool
// the full payload and any content they inject is bounded too. // interceptor observes exactly the model-visible durable result.
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = ( if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
self.tool_output_limits.as_ref(), self.tool_output_limits.as_ref(),
call_info, call_info,
@@ -1543,9 +1668,38 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
"Tool execution terminalized" "Tool execution terminalized"
); );
self.emit_tool_result(&tool_result); self.emit_tool_result(&tool_result);
if let Some(reason) = abort_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)); return Err(EngineError::Aborted(reason));
} }
}
Ok(true) 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 // These are committed *before* the per-request clone so they
// participate in the LLM request below and get persisted by // participate in the LLM request below and get persisted by
// the caller that owns durable history. // the caller that owns durable history.
let pending_invocation = self.interceptor_invocation(
InterceptorPhase::PendingHistoryAppends,
Some(current_turn),
None,
0,
);
let pending = self let pending = self
.interceptor .interceptor
.pending_history_appends() .pending_history_appends(PendingHistoryAppendsContext {
invocation: pending_invocation,
history: history.entries(),
})
.await .await
.map_err(EngineError::HistoryAppend)?; .map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PendingHistoryAppends,
error,
))
})?;
if !pending.is_empty() { if !pending.is_empty() {
self.append_history_items(history, pending, annotate)?; 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 // 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) => { PreRequestAction::Cancel(reason) => {
info!(reason = %reason, "Aborted by interceptor"); info!(reason = %reason, "Aborted by interceptor");
for cb in &self.turn_end_cbs { for cb in &self.turn_end_cbs {
@@ -1791,23 +1979,47 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let tool_calls = self.tool_call_collector.take_collected(); let tool_calls = self.tool_call_collector.take_collected();
let assistant_items = let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls); self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
let assistant_start = history.len();
self.append_history_items(history, assistant_items, annotate)?; self.append_history_items(history, assistant_items, annotate)?;
if tool_calls.is_empty() { let assistant_invocation = self.interceptor_invocation(
let turn_end_context = history.items_cloned(); InterceptorPhase::AssistantTurnEnd,
match self.interceptor.on_turn_end(&turn_end_context).await { Some(current_turn),
TurnEndAction::Finish => { 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); return Ok(EngineResult::Finished);
} }
TurnEndAction::Finish => {}
TurnEndAction::ContinueWithMessages(additional) => { TurnEndAction::ContinueWithMessages(additional) => {
self.append_history_items(history, additional, annotate)?; self.append_history_items(history, additional, annotate)?;
if tool_calls.is_empty() {
continue; continue;
} }
}
TurnEndAction::Pause => { TurnEndAction::Pause => {
return Ok(EngineResult::Paused); return Ok(EngineResult::Paused);
} }
} }
}
if let Some(result) = self if let Some(result) = self
.execute_and_commit_tools(history, annotate, tool_calls) .execute_and_commit_tools(history, annotate, tool_calls)
@@ -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). /// Create a new annotated Engine (in Mutable state).
pub fn new_annotated(client: C) -> Self { pub fn new_annotated(client: C) -> Self {
let text_block_collector = TextBlockCollector::new(); let text_block_collector = TextBlockCollector::new();
@@ -2126,6 +2338,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len: 0, locked_prefix_len: 0,
turn_count: 0, turn_count: 0,
active_run_turn_count: None, 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, llm_call_count: 0,
tool_execution_batch_count: 0, tool_execution_batch_count: 0,
max_turns: None, max_turns: None,
@@ -2401,6 +2617,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len, locked_prefix_len,
turn_count: self.turn_count, turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count, 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, llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count, tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns, max_turns: self.max_turns,
@@ -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 /// Execute a turn
/// ///
/// Adds a new user message to history and sends a request to the LLM. /// Adds a new user message to history and sends a request to the LLM.
@@ -2488,9 +2708,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
user_input: impl Into<String>, user_input: impl Into<String>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>, annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit { ) -> EngineRunExit {
self.run_result_with_annotation(history, user_input.into(), annotate) let result = self
.await .run_result_with_annotation(history, user_input.into(), annotate)
.into() .await;
self.finalize_run_exit(history, result).await
} }
async fn run_result_with_annotation( async fn run_result_with_annotation(
@@ -2501,13 +2722,26 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
) -> Result<EngineResult, EngineError> { ) -> Result<EngineResult, EngineError> {
// Supplying new user input abandons any paused/yielded logical run. // Supplying new user input abandons any paused/yielded logical run.
self.active_run_turn_count = None; 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 mut user_item = Item::user_message(user_input);
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await { let invocation = self.interceptor_invocation(InterceptorPhase::PromptSubmit, None, None, 0);
PromptAction::Cancel(reason) => { let prompt_action = self
return self .interceptor
.finalize_interruption(Err(EngineError::Aborted(reason))) .on_prompt_submit(PromptSubmitContext {
.await; 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::Continue => Vec::new(),
PromptAction::ContinueWith(items) => items, PromptAction::ContinueWith(items) => items,
}; };
@@ -2515,14 +2749,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
if !extras.is_empty() { if !extras.is_empty() {
self.append_history_items(history, extras, annotate)?; self.append_history_items(history, extras, annotate)?;
} }
self.start_logical_run(); match self.run_turn_loop(history, annotate).await {
let result = match self.run_turn_loop(history, annotate).await {
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused), Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other, other => other,
}; }
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Resume execution (from Paused state). /// Resume execution (from Paused state).
@@ -2531,9 +2761,8 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
history: &mut History<A>, history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>, annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit { ) -> EngineRunExit {
self.resume_result_with_annotation(history, annotate) let result = self.resume_result_with_annotation(history, annotate).await;
.await self.finalize_run_exit(history, result).await
.into()
} }
async fn resume_result_with_annotation( 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>, annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineResult, EngineError> { ) -> Result<EngineResult, EngineError> {
self.ensure_logical_run(); 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), Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other, other => other,
}; }
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Get the prefix length at lock time /// Get the prefix length at lock time
@@ -2574,6 +2800,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
locked_prefix_len: 0, locked_prefix_len: 0,
turn_count: self.turn_count, turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count, 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, llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count, tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns, max_turns: self.max_turns,
+250 -28
View File
@@ -9,8 +9,202 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::Item; use crate::Item;
use crate::engine::EngineRunExit;
use crate::history::HistoryEntry;
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult}; 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 // Action Enums
// ============================================================================= // =============================================================================
@@ -86,9 +280,9 @@ pub enum PostToolAction {
/// Action at the end of a turn (when LLM produces no tool calls). /// Action at the end of a turn (when LLM produces no tool calls).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum TurnEndAction { pub enum TurnEndAction {
/// Turn is finished, return to caller. /// Accept the Engine's natural next phase: execute tools, or finish when none exist.
Finish, Finish,
/// Continue with additional messages injected into history. /// Commit additional messages, then continue through the natural next phase.
ContinueWithMessages(Vec<Item>), ContinueWithMessages(Vec<Item>),
/// Pause execution (can be resumed later). /// Pause execution (can be resumed later).
Pause, Pause,
@@ -99,8 +293,9 @@ pub enum TurnEndAction {
// ============================================================================= // =============================================================================
/// Context for pre-tool-call decisions. /// Context for pre-tool-call decisions.
pub struct ToolCallInfo { pub struct ToolCallInfo<'a, A = ()> {
/// Tool call information (modifiable). pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall, pub call: ToolCall,
/// Tool meta information. /// Tool meta information.
pub meta: ToolMeta, pub meta: ToolMeta,
@@ -111,10 +306,11 @@ pub struct ToolCallInfo {
} }
/// Context for post-tool-call decisions. /// Context for post-tool-call decisions.
pub struct ToolResultInfo { pub struct ToolResultInfo<'a, A = ()> {
/// Original tool call. pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall, pub call: ToolCall,
/// Tool execution result (modifiable). /// Committed terminal tool execution result.
pub result: ToolResult, pub result: ToolResult,
/// Tool meta information. /// Tool meta information.
pub meta: ToolMeta, pub meta: ToolMeta,
@@ -130,14 +326,22 @@ pub struct ToolResultInfo {
/// Intercepts the Engine execution loop at key decision points. /// Intercepts the Engine execution loop at key decision points.
/// ///
/// All methods have default implementations that let the Engine /// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
/// proceed without intervention. Callers provide richer implementations for /// keeping implementation failure separate from the method's control-flow
/// approval flows, permission checks, etc. /// 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] #[async_trait]
pub trait Interceptor: Send + Sync { pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Called after receiving user input, before adding to history. /// Called after receiving user input, before adding it to Engine history.
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction { async fn on_prompt_submit(
PromptAction::Continue &self,
_context: PromptSubmitContext<'_, A>,
) -> InterceptorResult<PromptAction> {
Ok(PromptAction::Continue)
} }
/// Items that should be **committed to `engine.history`** just /// Items that should be **committed to `engine.history`** just
@@ -158,7 +362,10 @@ pub trait Interceptor: Send + Sync {
/// reproducible per-request transformations (pruning, content /// reproducible per-request transformations (pruning, content
/// trimming, cache anchors) that depend only on the existing /// trimming, cache anchors) that depend only on the existing
/// history. /// 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()) Ok(Vec::new())
} }
@@ -170,27 +377,42 @@ pub trait Interceptor: Send + Sync {
/// If an interceptor derives a human/model-visible nudge from the current /// If an interceptor derives a human/model-visible nudge from the current
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine /// request context, return [`PreRequestAction::ContinueWith`] so the Engine
/// commits it to history before the request is sent. /// commits it to history before the request is sent.
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction { async fn pre_llm_request(
PreRequestAction::Continue &self,
_context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
Ok(PreRequestAction::Continue)
} }
/// Called before each tool is executed. /// Called before each tool is executed.
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
PreToolAction::Continue &self,
_info: &mut ToolCallInfo<'_, A>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::Continue)
} }
/// Called after each tool completes. /// Called after each tool reaches one terminal result and that result is committed.
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
PostToolAction::Continue &self,
_info: &ToolResultInfo<'_, A>,
) -> InterceptorResult<PostToolAction> {
Ok(PostToolAction::Continue)
} }
/// Called when a turn ends with no tool calls. /// Called after every terminal assistant response is committed and before
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction { /// the Engine decides whether to execute tools, continue, or finish.
TurnEndAction::Finish async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, A>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Finish)
} }
/// Called when execution is interrupted (abort or cancel). /// Called once for the terminal outcome of each public run or resume call.
async fn on_abort(&self, _reason: &str) {} async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
Ok(())
}
} }
/// Default interceptor: no intervention. Engine proceeds through the loop /// Default interceptor: no intervention. Engine proceeds through the loop
@@ -198,4 +420,4 @@ pub trait Interceptor: Send + Sync {
pub(crate) struct DefaultInterceptor; pub(crate) struct DefaultInterceptor;
#[async_trait] #[async_trait]
impl Interceptor for DefaultInterceptor {} impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
+7 -1
View File
@@ -26,7 +26,13 @@ pub use engine::{
}; };
pub use handler::ToolUseBlockStart; pub use handler::ToolUseBlockStart;
pub use history::{History, HistoryEntry}; 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 message::{ContentPart, Item, Message, Role};
pub use tool::{ pub use tool::{
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy, ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
+126
View File
@@ -1,8 +1,15 @@
mod common; 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::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role}; use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
use std::sync::{Arc, Mutex};
fn completed_text_events(text: &str) -> Vec<Event> { fn completed_text_events(text: &str) -> Vec<Event> {
vec![ vec![
@@ -47,6 +54,125 @@ async fn run_preserves_item_annotations_without_projecting_them() {
assert_eq!(history.items_cloned().len(), 2); 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] #[test]
fn append_failure_does_not_make_item_live() { fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
+519 -10
View File
@@ -10,9 +10,16 @@ use std::sync::{Arc, Mutex};
use agen::Item; use agen::Item;
use agen::interceptor::{ 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::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason}; use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
use async_trait::async_trait; use async_trait::async_trait;
@@ -613,12 +620,15 @@ struct YieldOnce {
#[async_trait] #[async_trait]
impl Interceptor for YieldOnce { impl Interceptor for YieldOnce {
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction { async fn pre_llm_request(
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { &self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreRequestAction::Yield PreRequestAction::Yield
} else { } else {
PreRequestAction::Continue PreRequestAction::Continue
} })
} }
} }
@@ -628,12 +638,15 @@ struct PauseToolOnce {
#[async_trait] #[async_trait]
impl Interceptor for PauseToolOnce { impl Interceptor for PauseToolOnce {
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { &self,
_info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreToolAction::Pause PreToolAction::Pause
} else { } else {
PreToolAction::Continue PreToolAction::Continue
} })
} }
} }
@@ -643,13 +656,509 @@ struct ContinueTurnOnce {
#[async_trait] #[async_trait]
impl Interceptor for ContinueTurnOnce { impl Interceptor for ContinueTurnOnce {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction { async fn on_assistant_turn_end(
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { &self,
_context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")]) TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
} else { } else {
TurnEndAction::Finish 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] #[tokio::test]
+240 -40
View File
@@ -6,13 +6,18 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use agen::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::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{ use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
ToolResultDisposition, ToolResultDisposition,
}; };
use agen::{Engine, History, Item, ToolExecutionPolicy}; use agen::{
Engine, EngineError, EngineRunExit, History, Item, RunInterruptionReason, ToolExecutionPolicy,
};
use async_trait::async_trait; use async_trait::async_trait;
mod common; mod common;
@@ -905,24 +910,30 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
#[async_trait] #[async_trait]
impl Interceptor for ContextPolicy { 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()); 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, "skip_tool" => PreToolAction::Skip,
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output( "synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
&info.call.id, &info.call.id,
ToolOutput::from("synthetic result".to_string()), ToolOutput::from("synthetic result".to_string()),
)), )),
_ => PreToolAction::Continue, _ => 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 self.post_contexts
.lock() .lock()
.unwrap() .unwrap()
.push(info.context.clone()); .push(info.context.clone());
PostToolAction::Continue Ok(PostToolAction::Continue)
} }
} }
@@ -994,12 +1005,15 @@ async fn test_before_tool_call_skip() {
#[async_trait] #[async_trait]
impl Interceptor for BlockingPolicy { impl Interceptor for BlockingPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
if info.call.name == "blocked_tool" { &self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if info.call.name == "blocked_tool" {
PreToolAction::Skip PreToolAction::Skip
} else { } else {
PreToolAction::Continue 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] #[tokio::test]
async fn test_post_tool_call_modification() { async fn test_post_tool_call_observes_committed_result() {
// Prepare responses for multiple requests // Prepare responses for multiple requests
let client = MockLlmClient::with_responses(vec![ let client = MockLlmClient::with_responses(vec![
// First request: tool call // First request: tool call
@@ -1074,40 +1088,51 @@ async fn test_post_tool_call_modification() {
engine.register_tool(simple_tool_definition()); engine.register_tool(simple_tool_definition());
// Policy to modify results // Policy to observe the committed terminal result.
struct ModifyingPolicy { struct ObservingPolicy {
modified_content: Arc<std::sync::Mutex<Option<String>>>, observed_content: Arc<std::sync::Mutex<Option<String>>>,
} }
#[async_trait] #[async_trait]
impl Interceptor for ModifyingPolicy { impl Interceptor for ObservingPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
info.result.summary = format!("[Modified] {}", info.result.summary); &self,
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone()); info: &ToolResultInfo<'_, ()>,
PostToolAction::Continue ) -> 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)); let observed_content = Arc::new(std::sync::Mutex::new(None));
engine.set_interceptor(ModifyingPolicy { engine.set_interceptor(ObservingPolicy {
modified_content: modified_content.clone(), observed_content: observed_content.clone(),
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // 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!( assert!(
matches!(result.result, agen::EngineRunExit::Finished), matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete" "Engine should complete"
); );
// Verify hook was called and content was modified // Verify the interceptor observed the exact committed result.
let content = modified_content.lock().unwrap().clone(); let observed = observed_content.lock().unwrap().clone();
assert!(content.is_some(), "Hook should have been called"); assert_eq!(observed.as_deref(), Some("Original Result"));
assert!( assert!(history.items().any(|item| matches!(
content.unwrap().contains("[Modified]"), item,
"Result should be modified" Item::ToolResult { summary, .. } if summary == "Original Result"
); )));
} }
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history. /// 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] #[async_trait]
impl Interceptor for SyntheticPolicy { impl Interceptor for SyntheticPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
PreToolAction::SyntheticResult(ToolResult::error( &self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::SyntheticResult(ToolResult::error(
info.call.id.clone(), info.call.id.clone(),
"permission denied", "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] #[tokio::test]
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() { async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let client = MockLlmClient::new(vec![ 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); let tool = SlowTool::new("confirmed", 1);
engine.register_tool(tool.definition()); 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] #[async_trait]
impl Interceptor for AbortAfterResult { impl Interceptor for AbortAfterResult {
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
PostToolAction::Abort("policy stopped the run".to_string()) &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(); let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published")); engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone(); 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!(tool.call_count(), 1);
assert_eq!( assert_eq!(
observed.lock().unwrap().as_slice(), observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"] ["committed", "published", "post_tool_call", "run-returned"]
); );
assert!(matches!( assert!(matches!(
output.result, output.result,
@@ -1239,3 +1349,93 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
} if call_id == "call_confirmed" } 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));
}
}
+286 -3
View File
@@ -7,10 +7,16 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workspace_api::{ pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
RuntimeSummary as BackendRuntimeSummary,
WorkerCapabilitySummary as BackendWorkerCapabilitySummary, WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
WorkerImplementationSummary as BackendWorkerImplementationSummary, WorkerImplementationSummary as BackendWorkerImplementationSummary,
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
WorkerOperationState as BackendWorkerOperationState,
WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
@@ -171,6 +177,47 @@ struct UploadedFileResponse {
file: protocol::UploadedFileRef, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRuntimeListTarget { pub struct BackendRuntimeListTarget {
pub base_url: String, 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( pub async fn list_backend_workers(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> { ) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
@@ -462,6 +561,30 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
Ok(()) 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( fn validate_list_target(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<(), BackendRuntimeClientError> { ) -> Result<(), BackendRuntimeClientError> {
@@ -496,6 +619,17 @@ fn validate_list_target(
Ok(()) 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 { fn backend_runtimes_path(workspace_id: &str) -> String {
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn protocol_url_uses_backend_runtime_worker_identity() { fn protocol_url_uses_backend_runtime_worker_identity() {
@@ -631,7 +914,7 @@ mod tests {
"working_directory": { "working_directory": {
"working_directory_id": "wd-1", "working_directory_id": "wd-1",
"repository_key": "main", "repository_key": "main",
"materializer_kind": "local_git_worktree", "materializer_kind": "runtime_git_clone",
"status": "active", "status": "active",
"occupied_by": { "occupied_by": {
"runtime_id": "arcadia", "runtime_id": "arcadia",
+47 -20
View File
@@ -1,9 +1,9 @@
use crate::{BackendApiClient, BackendApiClientError}; use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{ use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord, InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
WorkspaceSummary, WorkspaceSummary,
}; };
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
pub type BackendWorkspace = WorkspaceSummary; pub type BackendWorkspace = WorkspaceSummary;
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse; pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord; pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRequest {
pub operation_key: String,
pub display_name: String,
pub repository: CreateBackendWorkspaceRepository,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRepository {
pub uri: String,
pub display_name: Option<String>,
pub default_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget { pub struct BackendWorkspaceCatalogTarget {
@@ -73,6 +58,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
} }
} }
pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
}
pub fn list_backend_workspace_repositories_blocking(
target: &BackendWorkspaceCatalogTarget,
workspace_id: &str,
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
if workspace_id.is_empty()
|| workspace_id.len() > 200
|| !workspace_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(BackendWorkspaceClientError::InvalidTarget(
"Workspace id returned by Backend is invalid".to_string(),
));
}
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
.send()?;
client.check_status(response.status())?;
let response = response.json::<RepositoryListResponse>()?;
if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(),
));
}
Ok(response.items)
}
pub async fn list_backend_workspaces( pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
@@ -149,8 +176,8 @@ mod tests {
operation_key: "workspace-create-1".to_string(), operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(), display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(), uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()), default_ref: Some("develop".to_string()),
}, },
}; };
+8 -7
View File
@@ -112,26 +112,27 @@ mod tests {
async fn encodes_methods_and_decodes_events_above_transport() { async fn encodes_methods_and_decodes_events_above_transport() {
let mut socket = TestSocket::default(); let mut socket = TestSocket::default();
socket.incoming.push_back( socket.incoming.push_back(
encode_event(&Event::Status { encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.expect("encode event"), .expect("encode event"),
); );
let mut client = Client::new(socket); let mut client = Client::new(socket);
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
assert!(matches!( assert!(matches!(
decode_method(&client.socket.sent[0]), decode_method(&client.socket.sent[0]),
Ok(Method::Run { .. }) Ok(Method::Submit { .. })
)); ));
assert!(matches!( assert!(matches!(
client.next_event().await, client.next_event().await,
Ok(Some(Event::Status { Ok(Some(Event::WorkerState { .. }))
status: WorkerStatus::Idle
}))
)); ));
} }
} }
+16 -10
View File
@@ -21,23 +21,29 @@ pub use backend_auth::{
poll_device_login, start_device_login, wait_for_device_login, poll_device_login, start_device_login, wait_for_device_login,
}; };
pub use backend_runtime::{ pub use backend_runtime::{
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError, BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic,
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker, BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
}; };
pub use backend_workspace::{ pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, CreateBackendWorkspaceResponse, create_backend_workspace,
list_backend_workspace_repositories_blocking, list_backend_workspaces,
list_backend_workspaces_blocking,
}; };
pub use client::{Client, ClientError}; pub use client::{Client, ClientError};
pub use target::{ pub use target::{
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent, BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection, StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
}; };
pub use workspace_api::{ pub use workspace_api::{
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition, CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
+22 -1
View File
@@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf};
use crate::{ use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget, BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget, BackendRuntimeTarget, BackendWorkerLaunchTarget,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -123,6 +123,11 @@ pub struct Dashboard {
pub workspace_id: String, pub workspace_id: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunch {
pub target: BackendWorkerLaunchTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList { pub struct WorkerList {
pub backend_target: BackendRuntimeListTarget, pub backend_target: BackendRuntimeListTarget,
@@ -199,6 +204,13 @@ pub trait Target: fmt::Debug + Send + Sync {
Err(TargetError::unsupported("Worker dashboard", self.kind())) 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> { fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported("Worker listing", self.kind())) 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> { fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList { Ok(WorkerList {
backend_target: BackendRuntimeListTarget::new( backend_target: BackendRuntimeListTarget::new(
+8 -7
View File
@@ -89,17 +89,20 @@ mod tests {
let mut client = Client::new(socket); let mut client = Client::new(socket);
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
assert!(matches!( assert!(matches!(
peer.next().await.as_deref().map(decode_method), peer.next().await.as_deref().map(decode_method),
Some(Ok(Method::Run { .. })) Some(Ok(Method::Submit { .. }))
)); ));
peer.send( peer.send(
encode_event(&Event::Status { encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.expect("encode event"), .expect("encode event"),
) )
@@ -107,9 +110,7 @@ mod tests {
.expect("send event"); .expect("send event");
assert!(matches!( assert!(matches!(
client.next_event().await, client.next_event().await,
Ok(Some(Event::Status { Ok(Some(Event::WorkerState { .. }))
status: WorkerStatus::Idle
}))
)); ));
} }
} }
+11 -10
View File
@@ -113,8 +113,8 @@ mod tests {
let listener = UnixListener::bind(&socket_path).unwrap(); let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move { let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap(); let (mut stream, _) = listener.accept().await.unwrap();
let event = encode_event(&Event::Status { let event = encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.unwrap(); .unwrap();
stream.write_all(event.as_bytes()).await.unwrap(); stream.write_all(event.as_bytes()).await.unwrap();
@@ -126,12 +126,7 @@ mod tests {
.await .await
.expect("client should receive event while alive") .expect("client should receive event while alive")
.expect("transport should succeed"); .expect("transport should succeed");
assert!(matches!( assert!(matches!(event, Some(Event::WorkerState { .. })));
event,
Some(Event::Status {
status: WorkerStatus::Idle
})
));
server.await.unwrap(); server.await.unwrap();
} }
@@ -147,12 +142,18 @@ mod tests {
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap()); let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
let received = server.await.unwrap().expect("method message"); 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] #[tokio::test]
+8 -7
View File
@@ -114,10 +114,10 @@ mod tests {
assert!(matches!( assert!(matches!(
message, message,
Message::Text(ref text) 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 { let event = encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.unwrap(); .unwrap();
socket.send(Message::Text(event.into())).await.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 request = format!("ws://{address}").into_client_request().unwrap();
let mut client = Client::new(Socket::connect(request).await.unwrap()); let mut client = Client::new(Socket::connect(request).await.unwrap());
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
assert!(matches!( assert!(matches!(
client.next_event().await, client.next_event().await,
Ok(Some(Event::Status { Ok(Some(Event::WorkerState { .. }))
status: WorkerStatus::Idle
}))
)); ));
server.await.unwrap(); server.await.unwrap();
} }
+39 -2
View File
@@ -12,8 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
}; };
use crate::{BackendApiClient, BackendWorkspaceClientError}; 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> { pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
self.get_json("/memory") self.get_json("/memory")
} }
+8 -1
View File
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
operation_key: "workspace-create-1".to_string(), operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(), display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(), uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()), default_ref: Some("develop".to_string()),
}, },
}; };
assert_eq!(request.clone(), request); assert_eq!(request.clone(), request);
assert_eq!(request.operation_key, "workspace-create-1"); assert_eq!(request.operation_key, "workspace-create-1");
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["repository_key"], "main");
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
assert!(json.get("operation_id").is_none());
assert!(json["repository"].get("display_name").is_none());
assert!(json["repository"].get("source").is_none());
} }
#[test] #[test]
+12 -14
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use globset::Glob; use globset::Glob;
use ignore::WalkBuilder; use ignore::WalkBuilder;
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink}; use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
/// Execute a bounded glob entirely inside the provider process. /// Execute a bounded glob entirely inside the provider process.
pub fn run_glob( pub fn run_glob(
@@ -15,26 +15,24 @@ pub fn run_glob(
if !root.is_absolute() { if !root.is_absolute() {
return Err(FsError::RelativePath(root.to_path_buf())); return Err(FsError::RelativePath(root.to_path_buf()));
} }
if !access.is_readable(base) { let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
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()), path: PathBuf::from(request.path.as_str()),
target: PathBuf::from("<provider-internal target>"), source: error,
}); })?;
if !access.is_readable_paths(base, &base_resolved) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
} }
let matcher = Glob::new(&request.pattern) let matcher = Glob::new(&request.pattern)
.map_err(|error| FsError::InvalidGlob(error.to_string()))? .map_err(|error| FsError::InvalidGlob(error.to_string()))?
.compile_matcher(); .compile_matcher();
let mut matches = Vec::new(); 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(); let path = entry.path();
if !path.is_file() || !access.is_readable(path) { let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !path.is_file() || !readable {
continue; continue;
} }
let relative = path.strip_prefix(base).unwrap_or(path); let relative = path.strip_prefix(base).unwrap_or(path);
+40 -9
View File
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
pub use glob::run_glob; pub use glob::run_glob;
pub use local::{run_edit, run_list, run_read, run_stat, run_write}; pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
pub use operation::*; pub use operation::*;
pub use search::run_grep; pub use search::run_grep;
@@ -22,6 +22,19 @@ pub use search::run_grep;
pub trait FsAccessPolicy: Send + Sync { pub trait FsAccessPolicy: Send + Sync {
fn is_readable(&self, path: &Path) -> bool; fn is_readable(&self, path: &Path) -> bool;
fn is_writable(&self, path: &Path) -> bool; fn is_writable(&self, path: &Path) -> bool;
/// Authorize both the Workdir-visible path and its provider-resolved
/// target. Implementations that do not distinguish symbolic-link identity
/// retain resolved-target semantics through the defaults.
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_readable(resolved)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_writable(resolved)
}
} }
/// First symlink encountered while resolving a provider path. /// First symlink encountered while resolving a provider path.
@@ -477,13 +490,14 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[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; use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap(); let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone()); let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap(); 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(); 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-file.rs"), root.join("file-link.rs")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).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_eq!(file_result.match_count, 1);
assert!(file_result.output.starts_with("file-link.rs\n")); assert!(file_result.output.starts_with("file-link.rs\n"));
let directory_error = run_grep( let directory_result = run_grep(
&root, &root,
root.join("directory-link"), root.join("directory-link"),
request("directory-link"), request("directory-link"),
&readable, &readable,
) )
.unwrap_err(); .unwrap();
assert!(matches!( assert_eq!(directory_result.match_count, 1);
directory_error, assert!(
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. } directory_result
if path == root.join("directory-link") .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( let broken_error = run_grep(
&root, &root,
+66 -28
View File
@@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -18,7 +19,8 @@ pub fn run_stat(
) -> Result<StatResult, FsError> { ) -> Result<StatResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
if !access.is_readable(&path) { let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
if !access.is_readable_paths(&path, &resolved) {
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str()))); return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
} }
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
@@ -45,7 +47,7 @@ pub fn run_read(
) -> Result<ReadResult, FsError> { ) -> Result<ReadResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; 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))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -99,7 +101,7 @@ pub fn run_write(
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let created = !path.exists(); let created = !path.exists();
if 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))?; let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -113,12 +115,8 @@ pub fn run_write(
if request.expected_hash.is_some() { if request.expected_hash.is_some() {
return Err(FsError::Conflict(logical.as_str().to_string())); return Err(FsError::Conflict(logical.as_str().to_string()));
} }
let parent = path.parent().ok_or_else(|| { let target = require_access(&path, &logical, access, true, true)?;
FsError::InvalidArgument(format!("{} has no parent", logical.as_str())) atomic_write(&target, &request.content, &logical)?;
})?;
let parent_logical = logical_parent(&logical);
require_access(parent, &parent_logical, access, true)?;
atomic_write(&path, &request.content, &logical)?;
} }
Ok(WriteResult { Ok(WriteResult {
bytes_written: request.content.len(), bytes_written: request.content.len(),
@@ -133,7 +131,7 @@ pub fn run_edit(
) -> Result<EditResult, FsError> { ) -> Result<EditResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; 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 bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
let actual_hash = hash_bytes(&bytes); let actual_hash = hash_bytes(&bytes);
if actual_hash != request.expected_hash { if actual_hash != request.expected_hash {
@@ -173,7 +171,8 @@ pub fn run_list(
) -> Result<ListResult, FsError> { ) -> Result<ListResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let 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))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() { if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
@@ -183,7 +182,15 @@ pub fn run_list(
for entry in read_dir { for entry in read_dir {
let entry = entry.map_err(|error| map_io(&logical, error))?; let entry = entry.map_err(|error| map_io(&logical, error))?;
let absolute = entry.path(); let absolute = entry.path();
if !access.is_readable(&absolute) { let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
})?;
let logical_absolute = logical_base.join(relative_to_base);
let resolved = match resolve_access_path(&absolute) {
Ok(resolved) => resolved,
Err(_) => continue,
};
if !access.is_readable_paths(&logical_absolute, &resolved) {
continue; continue;
} }
let link_metadata = let link_metadata =
@@ -203,7 +210,7 @@ pub fn run_list(
} else { } else {
EntryKind::Other EntryKind::Other
}; };
let relative = absolute.strip_prefix(root).map_err(|_| { let relative = logical_absolute.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string()) FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?; })?;
entries.push(ListEntry { entries.push(ListEntry {
@@ -247,19 +254,24 @@ fn require_access(
logical: &FsPath, logical: &FsPath,
access: &dyn FsAccessPolicy, access: &dyn FsAccessPolicy,
write: bool, write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> { ) -> Result<PathBuf, FsError> {
if let Some(info) = direct_symlink(path) { let symlink = direct_symlink(path);
if !info.target_exists { if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(FsError::BrokenSymlink { return Err(FsError::BrokenSymlink {
path: PathBuf::from(logical.as_str()), path: PathBuf::from(logical.as_str()),
link: PathBuf::from(logical.as_str()), link: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"), target: PathBuf::from("<provider-internal target>"),
}); });
} }
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
if let Some(info) = symlink {
let allowed = if write { let allowed = if write {
access.is_writable(&info.resolved_path) access.is_writable_paths(path, &resolved)
} else { } else {
access.is_readable(&info.resolved_path) access.is_readable_paths(path, &resolved)
}; };
if !allowed { if !allowed {
return Err(FsError::SymlinkOutOfScope { return Err(FsError::SymlinkOutOfScope {
@@ -268,21 +280,21 @@ fn require_access(
required_permission: if write { "write" } else { "read" }, 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 { return Err(FsError::SymlinkTargetIsDirectory {
path: PathBuf::from(logical.as_str()), path: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"), target: PathBuf::from("<provider-internal target>"),
}); });
} }
return Ok(info.resolved_path); return Ok(resolved);
} }
let allowed = if write { let allowed = if write {
access.is_writable(path) access.is_writable_paths(path, &resolved)
} else { } else {
access.is_readable(path) access.is_readable_paths(path, &resolved)
}; };
if allowed { if allowed {
Ok(path.to_path_buf()) Ok(resolved)
} else if write { } else if write {
Err(FsError::ReadOnly(PathBuf::from(logical.as_str()))) Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
} else { } else {
@@ -290,12 +302,38 @@ fn require_access(
} }
} }
fn logical_parent(path: &FsPath) -> FsPath { /// Resolve every existing component of an absolute provider path while
let parent = Path::new(path.as_str()) /// retaining a missing final tail for create operations. Dangling symlinks are
.parent() /// rejected because no resolved authority identity can be established.
.unwrap_or_else(|| Path::new("")) pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
.to_string_lossy(); let mut cursor = path;
FsPath::new(parent).unwrap_or_else(|_| FsPath::root()) let mut missing = Vec::<OsString>::new();
loop {
match fs::canonicalize(cursor) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return Ok(resolved);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if fs::symlink_metadata(cursor)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(error);
}
let name = cursor.file_name().ok_or(error)?;
missing.push(name.to_os_string());
cursor = cursor.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"path has no existing ancestor",
)
})?;
}
Err(error) => return Err(error),
}
}
} }
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> { fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
+23 -24
View File
@@ -10,7 +10,9 @@ use ignore::WalkBuilder;
use ignore::overrides::{Override, OverrideBuilder}; use ignore::overrides::{Override, OverrideBuilder};
use ignore::types::{Types, TypesBuilder}; use ignore::types::{Types, TypesBuilder};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink}; use crate::{
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
};
struct ContentLine { struct ContentLine {
path: PathBuf, path: PathBuf,
@@ -220,14 +222,28 @@ pub fn run_grep(
return Err(FsError::RelativePath(base)); return Err(FsError::RelativePath(base));
} }
let symlink = direct_symlink(&base); let symlink = direct_symlink(&base);
if !access.is_readable(&base) { if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.resolved_path.clone(),
});
}
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
if !access.is_readable_paths(&base, &resolved_base) {
return Err(if let Some(info) = symlink.as_ref() { return Err(if let Some(info) = symlink.as_ref() {
let link_parent_readable = info let link_parent_readable = info
.link_path .link_path
.parent() .parent()
.map(|parent| access.is_readable(parent)) .and_then(|parent| {
resolve_access_path(parent)
.ok()
.map(|resolved| access.is_readable_paths(parent, &resolved))
})
.unwrap_or(false); .unwrap_or(false);
if info.target_exists && link_parent_readable { if link_parent_readable {
FsError::SymlinkOutOfScope { FsError::SymlinkOutOfScope {
path: base.clone(), path: base.clone(),
target: info.resolved_path.clone(), target: info.resolved_path.clone(),
@@ -240,15 +256,6 @@ pub fn run_grep(
FsError::OutOfScope(base.clone()) FsError::OutOfScope(base.clone())
}); });
} }
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.target_path.clone(),
});
}
}
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() { let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e), _ => FsError::io(&base, e),
@@ -259,16 +266,6 @@ pub fn run_grep(
base.display() 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 filter_base = if base_meta.is_file() { root } else { &base };
let types = build_types(p.file_type.as_deref())?; let types = build_types(p.file_type.as_deref())?;
let overrides = build_overrides(filter_base, p.glob.as_deref())?; let overrides = build_overrides(filter_base, p.glob.as_deref())?;
@@ -331,7 +328,9 @@ pub fn run_grep(
continue; continue;
} }
let path = entry.path(); let path = entry.path();
if !access.is_readable(path) { let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !readable {
continue; continue;
} }
if scan_path( if scan_path(
+186 -100
View File
@@ -15,13 +15,13 @@ use serde::{Deserialize, Serialize};
use crate::defaults; use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig, MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig, MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
WorkerManifest, WorkerMeta, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
}; };
/// Partial-form Worker manifest. Every field is optional; one or more /// Partial-form Worker manifest. Every field is optional; one or more
@@ -54,10 +54,6 @@ pub struct WorkerManifestConfig {
/// disabled after cascade merge. /// disabled after cascade merge.
#[serde(default)] #[serde(default)]
pub feature: FeatureConfigPartial, pub feature: FeatureConfigPartial,
/// Explicit plugin package enablement entries. Discovery/resolution is a
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
/// Explicit Model Context Protocol provider declarations. Config parsing /// Explicit Model Context Protocol provider declarations. Config parsing
/// never starts a local MCP subprocess. /// never starts a local MCP subprocess.
#[serde(default)] #[serde(default)]
@@ -67,15 +63,13 @@ pub struct WorkerManifestConfig {
/// First-class web tool opt-in. See [`WebConfig`]. /// First-class web tool opt-in. See [`WebConfig`].
#[serde(default)] #[serde(default)]
pub web: Option<WebConfig>, pub web: Option<WebConfig>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills directories. See [`crate::SkillsConfig`]. /// External Agent Skills directories. See [`crate::SkillsConfig`].
#[serde(default)] #[serde(default)]
pub skills: Option<SkillsConfig>, pub skills: Option<SkillsConfig>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfigPartial { pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub task: Option<FeatureFlagConfigPartial>, pub task: Option<FeatureFlagConfigPartial>,
@@ -103,8 +97,6 @@ pub struct FeatureConfigPartial {
pub merge_request: Option<MergeRequestFeatureConfigPartial>, pub merge_request: Option<MergeRequestFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>, pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
} }
impl FeatureConfigPartial { impl FeatureConfigPartial {
@@ -147,7 +139,6 @@ impl FeatureConfigPartial {
other.orchestration, other.orchestration,
FeatureFlagConfigPartial::merge, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryFeatureConfigPartial { pub struct MemoryFeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub enabled: Option<bool>, pub enabled: Option<bool>,
#[serde(default)] #[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 { impl MemoryFeatureConfigPartial {
fn merge(self, other: Self) -> Self { fn merge(self, other: Self) -> Self {
Self { Self {
enabled: other.enabled.or(self.enabled), 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 { impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self { fn from(value: FeatureConfigPartial) -> Self {
Self { Self {
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(), task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
memory: value memory: value
.memory .memory
.map(MemoryFeatureConfig::from) .map(ResolvedMemoryFeatureConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(), web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
image: value.image.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 .orchestration
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
.unwrap_or_default(), .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 { 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 { Self {
profile: MemoryFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(), enabled: value.enabled.unwrap_or_default(),
staging: value.staging.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 { impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: MemoryFeatureConfig) -> Self { fn from(value: ResolvedMemoryFeatureConfig) -> Self {
Self { Self {
enabled: Some(value.enabled), enabled: Some(value.profile.enabled),
staging: Some(value.staging), 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()), ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()), merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()), orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()),
} }
} }
} }
@@ -543,13 +637,23 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
(removed; use compaction.prune_protected_tokens)", (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 if value
.get("memory") .get("feature")
.and_then(toml::Value::as_table) .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( 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 if value
@@ -633,11 +737,6 @@ impl WorkerManifestConfig {
for rule in &mut self.delegation_scope.deny { for rule in &mut self.delegation_scope.deny {
rule.target = join_if_relative(base, &rule.target); 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 if let Some(ref mut compaction) = self.compaction
&& let Some(ref mut cp) = compaction.model && let Some(ref mut cp) = compaction.model
{ {
@@ -674,7 +773,6 @@ impl WorkerManifestConfig {
PermissionConfigPartial::merge, PermissionConfigPartial::merge,
), ),
feature: self.feature.merge(upper.feature), feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
mcp: merge_mcp_config(self.mcp, upper.mcp), mcp: merge_mcp_config(self.mcp, upper.mcp),
compaction: merge_option( compaction: merge_option(
self.compaction, self.compaction,
@@ -682,7 +780,6 @@ impl WorkerManifestConfig {
CompactionConfigPartial::merge, CompactionConfigPartial::merge,
), ),
web: merge_option(self.web, upper.web, WebConfig::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), 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 { fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
base.stdio_servers.extend(upper.stdio_servers); base.stdio_servers.extend(upper.stdio_servers);
base base
@@ -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 { impl WorkerMetaConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> Self {
Self { Self {
@@ -1219,11 +1280,9 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
session, session,
permissions, permissions,
feature: FeatureConfig::from(cfg.feature), feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
mcp: cfg.mcp, mcp: cfg.mcp,
compaction, compaction,
web: cfg.web, web: cfg.web,
memory: cfg.memory,
skills: cfg.skills, skills: cfg.skills,
profile: None, profile: None,
}) })
@@ -1260,18 +1319,17 @@ mod tests {
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
delegation_scope: ScopeConfig::default(), delegation_scope: ScopeConfig::default(),
permissions: None, permissions: None,
feature: FeatureConfigPartial::default(), feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
mcp: McpConfig::default(), mcp: McpConfig::default(),
session: None, session: None,
compaction: None, compaction: None,
web: None, web: None,
memory: None,
skills: None, skills: None,
} }
} }
@@ -1507,6 +1565,7 @@ mod tests {
target: PathBuf::from("secrets"), target: PathBuf::from("secrets"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
let resolved = cfg.resolve_paths(Path::new("/workspace/proj")); let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj")); assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
@@ -1644,6 +1703,7 @@ mod tests {
target: abs("/a"), target: abs("/a"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -1655,11 +1715,13 @@ mod tests {
target: abs("/b"), target: abs("/b"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: abs("/a/secret"), target: abs("/a/secret"),
permission: Permission::Read, permission: Permission::Read,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}], }],
}, },
..Default::default() ..Default::default()
@@ -1846,29 +1908,50 @@ prune_protected_turns = 3
} }
#[test] #[test]
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() { fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
let bad = r#" let cfg = WorkerManifestConfig::from_toml(
[memory] r#"
extract_worker_max_input_tokens = 30000 [feature.memory]
"#; enabled = true
let err = WorkerManifestConfig::from_toml(bad).unwrap_err(); staging_tools = false
assert!(
err.to_string() [feature.memory.resident]
.contains("memory.extract_worker_max_input_tokens"), inject_summary = false
"unexpected error: {err}"
); [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] #[test]
fn from_toml_accepts_extract_worker_max_turns() { fn from_toml_rejects_legacy_top_level_memory_authority() {
let cfg = WorkerManifestConfig::from_toml( let err = WorkerManifestConfig::from_toml(
r#" r#"
[memory] [memory]
extract_worker_max_turns = 2 extract_worker_max_turns = 2
"#, "#,
) )
.unwrap(); .unwrap_err();
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2)); assert!(
err.to_string().contains("memory"),
"unexpected error: {err}"
);
} }
#[test] #[test]
@@ -1948,7 +2031,7 @@ worker_max_turns = 7
fn feature_flags_default_disabled_in_resolved_manifest() { fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled); assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled); assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
@@ -2002,6 +2085,7 @@ enabled = false
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -2025,8 +2109,8 @@ enabled = false
} }
); );
assert!(!manifest.feature.orchestration.enabled); assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.memory.staging); assert!(!manifest.feature.memory.profile.staging_tools);
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
} }
@@ -2074,7 +2158,7 @@ readiness_check = true
enabled = true enabled = true
[feature.memory] [feature.memory]
staging = true staging_tools = true
[feature.manage_workdir] [feature.manage_workdir]
enabled = true enabled = true
@@ -2104,6 +2188,7 @@ enabled = true
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -2111,8 +2196,8 @@ enabled = true
}) })
.try_into() .try_into()
.unwrap(); .unwrap();
assert!(manifest.feature.memory.enabled); assert!(manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.staging); assert!(manifest.feature.memory.profile.staging_tools);
assert!(manifest.feature.manage_workdir.enabled); assert!(manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.ticket.enabled); assert!(manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket.authoring); assert!(!manifest.feature.ticket.authoring);
@@ -2180,6 +2265,7 @@ permission = "write"
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
+1 -1
View File
@@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5; pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited. /// 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); pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
+666 -177
View File
@@ -29,7 +29,7 @@ pub use profile::{
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value, WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
validate_profile_execution_target, validate_profile_execution_target,
}; };
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule, SymlinkPolicy};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
@@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize};
/// part of the manifest — it is the process's `std::env::current_dir()` /// part of the manifest — it is the process's `std::env::current_dir()`
/// at construction time. /// at construction time.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerManifest { pub struct WorkerManifest {
pub worker: WorkerMeta, pub worker: WorkerMeta,
pub model: ModelManifest, pub model: ModelManifest,
@@ -68,10 +69,6 @@ pub struct WorkerManifest {
/// resolve disabled so Profile authors choose the exposed built-in surfaces. /// resolve disabled so Profile authors choose the exposed built-in surfaces.
#[serde(default)] #[serde(default)]
pub feature: FeatureConfig, pub feature: FeatureConfig,
/// Explicit plugin package enablement. Discovery remains read-only; only
/// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)]
pub plugins: plugin::PluginConfig,
/// Explicit external Model Context Protocol provider configuration. This /// Explicit external Model Context Protocol provider configuration. This
/// is config data only: declaring a server never starts a subprocess or /// is config data only: declaring a server never starts a subprocess or
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate /// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
@@ -80,11 +77,6 @@ pub struct WorkerManifest {
pub mcp: McpConfig, pub mcp: McpConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfig>, 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 /// First-class web tools configuration. Network access remains fail-closed
/// under this config; WebSearch/WebFetch schemas are surfaced only when /// under this config; WebSearch/WebFetch schemas are surfaced only when
/// `[feature.web].enabled = true`. /// `[feature.web].enabled = true`.
@@ -109,12 +101,13 @@ pub struct WorkerManifest {
/// profile/config data only: they do not carry runtime Worker names, sockets, /// profile/config data only: they do not carry runtime Worker names, sockets,
/// sessions, secrets, or resolved host state. Tool registration still applies /// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks. /// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfig { pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub task: FeatureFlagConfig, pub task: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub memory: MemoryFeatureConfig, pub memory: ResolvedMemoryFeatureConfig,
#[serde(default)] #[serde(default)]
pub web: FeatureFlagConfig, pub web: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
@@ -139,15 +132,13 @@ pub struct FeatureConfig {
pub merge_request: MergeRequestFeatureConfig, pub merge_request: MergeRequestFeatureConfig,
#[serde(default)] #[serde(default)]
pub orchestration: FeatureFlagConfig, pub orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
} }
impl Default for FeatureConfig { impl Default for FeatureConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
task: FeatureFlagConfig::disabled(), task: FeatureFlagConfig::disabled(),
memory: MemoryFeatureConfig::disabled(), memory: ResolvedMemoryFeatureConfig::default(),
web: FeatureFlagConfig::disabled(), web: FeatureFlagConfig::disabled(),
image: FeatureFlagConfig::disabled(), image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(), sub_worker: FeatureFlagConfig::disabled(),
@@ -159,7 +150,6 @@ impl Default for FeatureConfig {
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(), orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
} }
} }
} }
@@ -222,34 +212,139 @@ const fn default_true() -> bool {
true true
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryFeatureConfig { #[serde(default, deny_unknown_fields)]
#[serde(default)] pub struct MemoryFeatureProfileConfig {
pub enabled: bool, pub enabled: bool,
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools. /// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
#[serde(default)] pub staging_tools: bool,
pub staging: bool, pub resident: MemoryResidentProfileConfig,
pub extraction: MemoryExtractionProfileConfig,
pub consolidation: MemoryConsolidationProfileConfig,
} }
impl MemoryFeatureConfig { impl MemoryFeatureProfileConfig {
pub const fn disabled() -> Self { pub fn disabled() -> Self {
Self { Self::default()
enabled: false,
staging: false,
}
} }
pub const fn enabled() -> Self { pub fn enabled() -> Self {
Self { Self {
enabled: true, enabled: true,
staging: false, ..Self::default()
} }
} }
} }
impl Default for MemoryFeatureConfig { impl Default for MemoryFeatureProfileConfig {
fn default() -> Self { 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, 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. /// Worker metadata.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerMeta { pub struct WorkerMeta {
@@ -931,6 +934,10 @@ impl Default for CompactionConfig {
} }
impl WorkerManifest { 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. /// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> { pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1101,33 +1369,61 @@ model_id = "claude-sonnet-4-20250514"
} }
#[test] #[test]
fn parse_plugin_enablement_config() { fn dynamic_plugin_manifest_config_is_rejected() {
let toml = format!( let toml = format!(
"{MINIMAL_REQUIRED}\n\ "{MINIMAL_REQUIRED}\n\
[[plugins.enabled]]\n\ [[plugins.enabled]]\n\
id = \"project:example\"\n\ id = \"project:example\"\n"
version = \"0.1.0\"\n\
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
surfaces = [\"hook\"]\n\n\
[plugins.enabled.config]\n\
greeting = \"hello\"\n"
); );
let manifest = WorkerManifest::from_toml(&toml).unwrap(); let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert_eq!(manifest.plugins.enabled.len(), 1); assert!(
let enabled = &manifest.plugins.enabled[0]; error
assert_eq!(enabled.id, "project:example"); .to_string()
assert_eq!( .contains("dynamic Plugins are not supported"),
enabled.version.as_ref().map(|version| version.0.as_str()), "unexpected error: {error}"
Some("0.1.0")
); );
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]); }
assert_eq!(
enabled #[test]
.config fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() {
.as_ref() let base =
.and_then(|value| value.get("greeting")) serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
.and_then(|value| value.as_str()),
Some("hello") let mut top_level = base.clone();
top_level.as_object_mut().unwrap().insert(
"plugins".to_string(),
serde_json::json!({
"resolved": [{
"package_path": "/tmp/ambient.yoi-plugin"
}]
}),
);
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
let mut nested = base;
nested
.get_mut("feature")
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"plugins".to_string(),
serde_json::json!({ "enabled": true }),
);
let error = serde_json::from_value::<WorkerManifest>(nested).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
}
#[test]
fn dynamic_plugin_feature_flag_is_rejected() {
let toml = format!("{MINIMAL_REQUIRED}\n[feature.plugins]\nenabled = true\n");
let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!(
error
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}"
); );
} }
@@ -1246,36 +1542,237 @@ model_id = "claude-sonnet-4-20250514"
} }
#[test] #[test]
fn omitted_memory_is_none() { fn omitted_memory_feature_is_disabled() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap(); 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] #[test]
fn empty_memory_section_enables_with_default_root() { fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n"); 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 manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.expect("memory section parsed"); assert!(manifest.feature.memory.profile.enabled);
assert!(mem.workspace_root.is_none()); assert!(!manifest.feature.memory.profile.resident.inject_summary);
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_eq!( assert_eq!(
mem.workspace_root.unwrap(), manifest.feature.memory.profile.extraction.threshold,
std::path::PathBuf::from("/some/where") 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(&current).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] #[test]
fn reject_unknown_scheme() { fn reject_unknown_scheme() {
let toml = let toml =
File diff suppressed because it is too large Load Diff
+56 -72
View File
@@ -18,11 +18,10 @@ use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
}; };
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{ use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError, EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
WorkerMetaConfig, paths, paths,
}; };
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
@@ -148,7 +147,6 @@ pub enum WorkspaceAuthorityRequirement {
MergeRequest, MergeRequest,
Objective, Objective,
Orchestration, Orchestration,
Plugins,
Ticket, Ticket,
Worker, Worker,
} }
@@ -162,7 +160,6 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
Self::MergeRequest => formatter.write_str("feature.merge_request"), Self::MergeRequest => formatter.write_str("feature.merge_request"),
Self::Objective => formatter.write_str("feature.objective"), Self::Objective => formatter.write_str("feature.objective"),
Self::Orchestration => formatter.write_str("feature.orchestration"), Self::Orchestration => formatter.write_str("feature.orchestration"),
Self::Plugins => formatter.write_str("feature.plugins or plugin packages"),
Self::Ticket => formatter.write_str("feature.ticket"), Self::Ticket => formatter.write_str("feature.ticket"),
Self::Worker => formatter.write_str("feature.worker"), Self::Worker => formatter.write_str("feature.worker"),
} }
@@ -185,7 +182,7 @@ pub fn validate_profile_execution_target(
if feature.manage_workdir.enabled { if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); 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); requirements.insert(WorkspaceAuthorityRequirement::Memory);
} }
if feature.merge_request.show if feature.merge_request.show
@@ -202,9 +199,6 @@ pub fn validate_profile_execution_target(
if feature.orchestration.enabled { if feature.orchestration.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Orchestration); requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
} }
if feature.plugins.enabled || !manifest.plugins.is_empty() {
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
}
if feature.ticket.enabled if feature.ticket.enabled
|| feature.ticket.authoring || feature.ticket.authoring
|| feature.ticket.thread || feature.ticket.thread
@@ -638,11 +632,9 @@ fn resolve_profile_value(
session: profile.session, session: profile.session,
permissions: profile.permissions, permissions: profile.permissions,
feature: profile.feature, feature: profile.feature,
plugins: profile.plugins,
mcp: profile.mcp, mcp: profile.mcp,
compaction, compaction,
web: profile.web, web: profile.web,
memory: profile.memory.map(Into::into),
skills: profile.skills, skills: profile.skills,
}; };
let config = 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)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct ProfileConfig { struct ProfileConfig {
@@ -730,16 +677,12 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
feature: FeatureConfigPartial, feature: FeatureConfigPartial,
#[serde(default)] #[serde(default)]
plugins: PluginConfig,
#[serde(default)]
mcp: McpConfig, mcp: McpConfig,
#[serde(default)] #[serde(default)]
compaction: Option<serde_json::Value>, compaction: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
web: Option<WebConfig>, web: Option<WebConfig>,
#[serde(default)] #[serde(default)]
memory: Option<ProfileMemoryConfig>,
#[serde(default)]
skills: Option<SkillsConfig>, skills: Option<SkillsConfig>,
} }
@@ -940,12 +883,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
.map_err(|source| ProfileError::ProfileDeserialize { source })?; .map_err(|source| ProfileError::ProfileDeserialize { source })?;
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?; 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 { if let Some(skills) = &profile.skills {
for dir in &skills.directories { for dir in &skills.directories {
if dir.is_absolute() { if dir.is_absolute() {
@@ -1024,6 +961,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.join(path), target: workspace_base.join(path),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
} }
Ok(ScopeConfig { Ok(ScopeConfig {
@@ -1031,6 +969,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.to_path_buf(), target: workspace_base.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny, deny,
}) })
@@ -1299,7 +1238,9 @@ mod tests {
("settings_revision", serde_json::json!(2)), ("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")), ("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( let error = resolve_profile_artifact_value(
artifact, artifact,
ProfileSource::Registry { 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] #[test]
fn builtin_default_resolves_as_a_standalone_local_capability_profile() { fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -1351,14 +1337,12 @@ mod tests {
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| { assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
rule.permission == protocol::Permission::Write && rule.target == tmp.path() 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.ticket.enabled);
assert!(!resolved.manifest.feature.objective.enabled); assert!(!resolved.manifest.feature.objective.enabled);
assert!(!resolved.manifest.feature.flow.enabled); assert!(!resolved.manifest.feature.flow.enabled);
assert!(!resolved.manifest.feature.worker.enabled); assert!(!resolved.manifest.feature.worker.enabled);
assert!(!resolved.manifest.feature.manage_workdir.enabled); assert!(!resolved.manifest.feature.manage_workdir.enabled);
assert!(!resolved.manifest.feature.plugins.enabled);
assert!(resolved.manifest.plugins.is_empty());
} }
#[test] #[test]
@@ -1630,7 +1614,7 @@ enabled = false
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-worker"); assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert!(resolved.manifest.feature.task.enabled); 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.web.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled); assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.feature.ticket.enabled); assert!(resolved.manifest.feature.ticket.enabled);
+271 -69
View File
@@ -3,16 +3,17 @@
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every //! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path //! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`]. //! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are canonicalised (where //! All rule targets retain both their lexically normalized logical identity and
//! possible) so access checks are pure path comparisons. //! 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::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use arc_swap::{ArcSwap, Guard}; use arc_swap::{ArcSwap, Guard};
use crate::{Permission, ScopeConfig, ScopeRule}; use crate::{Permission, ScopeConfig, ScopeRule, SymlinkPolicy};
/// Parsed, pwd-resolved set of allow/deny rules for a Worker. /// Parsed, pwd-resolved set of allow/deny rules for a Worker.
/// ///
@@ -26,10 +27,13 @@ pub struct Scope {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedRule { struct ResolvedRule {
/// Absolute, canonicalized-or-normalized target directory/file. /// Absolute, lexically normalized target as presented through the Workdir.
target: PathBuf, logical_target: PathBuf,
/// Absolute target after provider-side symbolic-link resolution.
resolved_target: PathBuf,
permission: Permission, permission: Permission,
recursive: bool, recursive: bool,
symlink_policy: SymlinkPolicy,
} }
/// Parsed filesystem authority this Worker may pass to spawned children. /// Parsed filesystem authority this Worker may pass to spawned children.
@@ -98,18 +102,46 @@ fn permission_denies_requested(denied: Permission, requested: Permission) -> boo
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool { fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_covers(available.permission, requested.permission) permission_covers(available.permission, requested.permission)
&& rule_path_set_contains(available, requested) && available.symlink_policy >= requested.symlink_policy
&& rule_path_set_contains(
available,
requested,
match available.symlink_policy {
SymlinkPolicy::Resolved => RuleIdentity::Resolved,
SymlinkPolicy::Logical => RuleIdentity::Logical,
},
)
} }
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool { fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_denies_requested(deny.permission, requested.permission) permission_denies_requested(deny.permission, requested.permission)
&& rule_path_sets_overlap(deny, requested) && (rule_path_sets_overlap(deny, requested, RuleIdentity::Logical)
|| rule_path_sets_overlap(deny, requested, RuleIdentity::Resolved))
} }
fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) -> bool { #[derive(Clone, Copy)]
enum RuleIdentity {
Logical,
Resolved,
}
fn rule_target(rule: &ResolvedRule, identity: RuleIdentity) -> &Path {
match identity {
RuleIdentity::Logical => &rule.logical_target,
RuleIdentity::Resolved => &rule.resolved_target,
}
}
fn rule_path_set_contains(
available: &ResolvedRule,
requested: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let available_target = rule_target(available, identity);
let requested_target = rule_target(requested, identity);
match (available.recursive, requested.recursive) { match (available.recursive, requested.recursive) {
// A recursive grant contains every possible requested path below its target. // A recursive grant contains every possible requested path below its target.
(true, _) => requested.target.starts_with(&available.target), (true, _) => requested_target.starts_with(available_target),
// A non-recursive grant contains only the target and its direct children; // A non-recursive grant contains only the target and its direct children;
// a recursive request always includes descendants beyond that finite-depth // a recursive request always includes descendants beyond that finite-depth
// set. // set.
@@ -117,36 +149,42 @@ fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) ->
// Two non-recursive rules have the same finite-depth set only when their // Two non-recursive rules have the same finite-depth set only when their
// target is identical. A request rooted at a direct child would also grant // target is identical. A request rooted at a direct child would also grant
// that child's children, which are grandchildren of `available.target`. // that child's children, which are grandchildren of `available.target`.
(false, false) => requested.target == available.target, (false, false) => requested_target == available_target,
} }
} }
fn rule_path_sets_overlap(left: &ResolvedRule, right: &ResolvedRule) -> bool { fn rule_path_sets_overlap(
left: &ResolvedRule,
right: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let left_target = rule_target(left, identity);
let right_target = rule_target(right, identity);
match (left.recursive, right.recursive) { match (left.recursive, right.recursive) {
(true, true) => { (true, true) => {
left.target.starts_with(&right.target) || right.target.starts_with(&left.target) left_target.starts_with(right_target) || right_target.starts_with(left_target)
} }
(true, false) => recursive_and_non_recursive_sets_overlap(left, right), (true, false) => recursive_and_non_recursive_sets_overlap(left_target, right_target),
(false, true) => recursive_and_non_recursive_sets_overlap(right, left), (false, true) => recursive_and_non_recursive_sets_overlap(right_target, left_target),
(false, false) => { (false, false) => {
left.target == right.target left_target == right_target
|| direct_child(&left.target, &right.target) || direct_child(left_target, right_target)
|| direct_child(&right.target, &left.target) || direct_child(right_target, left_target)
} }
} }
} }
fn recursive_and_non_recursive_sets_overlap( fn recursive_and_non_recursive_sets_overlap(
recursive: &ResolvedRule, recursive_target: &Path,
non_recursive: &ResolvedRule, non_recursive_target: &Path,
) -> bool { ) -> bool {
// The non-recursive set is `{target} + direct children`. It overlaps a // The non-recursive set is `{target} + direct children`. It overlaps a
// recursive subtree when either the non-recursive target is inside that // recursive subtree when either the non-recursive target is inside that
// subtree, or the recursive subtree begins at the non-recursive target or // subtree, or the recursive subtree begins at the non-recursive target or
// one of its direct children. // one of its direct children.
non_recursive.target.starts_with(&recursive.target) non_recursive_target.starts_with(recursive_target)
|| recursive.target == non_recursive.target || recursive_target == non_recursive_target
|| direct_child(&recursive.target, &non_recursive.target) || direct_child(recursive_target, non_recursive_target)
} }
fn direct_child(child: &Path, parent: &Path) -> bool { fn direct_child(child: &Path, parent: &Path) -> bool {
@@ -201,23 +239,35 @@ impl Scope {
} }
/// Convenience constructor for tests and simple setups: a single /// Convenience constructor for tests and simple setups: a single
/// recursive `allow(Write)` rule rooted at `root`. /// recursive `allow(Write)` rule rooted at `root` with the default
/// resolved-target symlink policy.
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> { pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
let root = 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 { Ok(Self {
allow: vec![ResolvedRule { allow: vec![ResolvedRule {
target: root, logical_target: root,
resolved_target: resolved_root,
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}], }],
deny: Vec::new(), deny: Vec::new(),
}) })
} }
/// Resolve one rule target with the same symlink and missing-tail semantics /// Return one rule target in the identity selected by its symlink policy.
/// used by scope matching.
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> { pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
Ok(resolve_rule(rule)?.target) let rule = resolve_rule(rule)?;
Ok(match rule.symlink_policy {
SymlinkPolicy::Resolved => rule.resolved_target,
SymlinkPolicy::Logical => rule.logical_target,
})
} }
/// Return whether this effective scope fully contains a requested rule. /// Return whether this effective scope fully contains a requested rule.
@@ -244,10 +294,23 @@ impl Scope {
/// Returns `None` when `path` is outside every allow rule, or when /// Returns `None` when `path` is outside every allow rule, or when
/// deny rules have knocked it below `Read`. /// deny rules have knocked it below `Read`.
pub fn permission_at(&self, path: &Path) -> Option<Permission> { pub fn permission_at(&self, path: &Path) -> Option<Permission> {
let resolved = 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; let mut effective: Option<Permission> = None;
for rule in &self.allow { for rule in &self.allow {
if rule.matches(&resolved) { let candidate = match rule.symlink_policy {
SymlinkPolicy::Resolved => &resolved,
SymlinkPolicy::Logical => &logical,
};
if rule.matches(candidate, rule.symlink_policy) {
effective = match effective { effective = match effective {
None => Some(rule.permission), None => Some(rule.permission),
Some(cur) => Some(cur.max(rule.permission)), Some(cur) => Some(cur.max(rule.permission)),
@@ -256,11 +319,13 @@ impl Scope {
} }
let mut effective = effective?; let mut effective = effective?;
// Deny: min(min_deny) dictates the cap. Effective level is capped // Deny rules always inspect both identities. This prevents a logical
// strictly below that value, so deny(read) wipes access entirely. // alias or a second symlink to the same target from bypassing a deny.
let mut min_deny: Option<Permission> = None; let mut min_deny: Option<Permission> = None;
for rule in &self.deny { for rule in &self.deny {
if rule.matches(&resolved) { if rule.matches(&logical, SymlinkPolicy::Logical)
|| rule.matches(&resolved, SymlinkPolicy::Resolved)
{
min_deny = match min_deny { min_deny = match min_deny {
None => Some(rule.permission), None => Some(rule.permission),
Some(cur) => Some(cur.min(rule.permission)), Some(cur) => Some(cur.min(rule.permission)),
@@ -293,7 +358,7 @@ impl Scope {
/// rule, preserving declaration order. Does not account for deny /// rule, preserving declaration order. Does not account for deny
/// rules, which only cap effective permission at query time. /// rules, which only cap effective permission at query time.
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> { pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
self.allow.iter().map(|r| r.target.as_path()) self.allow.iter().map(|r| r.logical_target.as_path())
} }
/// Allow rules with their targets resolved to absolute paths. /// Allow rules with their targets resolved to absolute paths.
@@ -305,9 +370,10 @@ impl Scope {
self.allow self.allow
.iter() .iter()
.map(|r| ScopeRule { .map(|r| ScopeRule {
target: r.target.clone(), target: r.logical_target.clone(),
permission: r.permission, permission: r.permission,
recursive: r.recursive, recursive: r.recursive,
symlink_policy: r.symlink_policy,
}) })
.collect() .collect()
} }
@@ -322,9 +388,10 @@ impl Scope {
self.deny self.deny
.iter() .iter()
.map(|r| ScopeRule { .map(|r| ScopeRule {
target: r.target.clone(), target: r.logical_target.clone(),
permission: r.permission, permission: r.permission,
recursive: r.recursive, recursive: r.recursive,
symlink_policy: r.symlink_policy,
}) })
.collect() .collect()
} }
@@ -335,7 +402,7 @@ impl Scope {
self.allow self.allow
.iter() .iter()
.filter(|r| r.permission == Permission::Write) .filter(|r| r.permission == Permission::Write)
.map(|r| r.target.as_path()) .map(|r| r.logical_target.as_path())
} }
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended /// Build a new [`Scope`] equal to `self` with `extra_allow` appended
@@ -412,7 +479,10 @@ impl Scope {
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
fn push_rule(out: &mut String, rule: &ResolvedRule) { fn push_rule(out: &mut String, rule: &ResolvedRule) {
out.push_str(" - "); out.push_str(" - ");
out.push_str(&rule.target.display().to_string()); out.push_str(&rule.logical_target.display().to_string());
if rule.symlink_policy == SymlinkPolicy::Logical {
out.push_str(" [logical-symlinks]");
}
if !rule.recursive { if !rule.recursive {
out.push_str(" [non-recursive]"); out.push_str(" [non-recursive]");
} }
@@ -510,11 +580,15 @@ impl SharedScope {
} }
impl ResolvedRule { impl ResolvedRule {
fn matches(&self, path: &Path) -> bool { fn matches(&self, path: &Path, identity: SymlinkPolicy) -> bool {
let target = match identity {
SymlinkPolicy::Resolved => &self.resolved_target,
SymlinkPolicy::Logical => &self.logical_target,
};
if self.recursive { if self.recursive {
path.starts_with(&self.target) path.starts_with(target)
} else { } else {
path == self.target || path.parent() == Some(self.target.as_path()) path == target || path.parent() == Some(target.as_path())
} }
} }
} }
@@ -523,48 +597,84 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
if !rule.target.is_absolute() { if !rule.target.is_absolute() {
return Err(ScopeError::RelativeTarget(rule.target.clone())); return Err(ScopeError::RelativeTarget(rule.target.clone()));
} }
let target = 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(), path: rule.target.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"), source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
})?; })?;
let resolved_target =
resolve_path(&logical_target).map_err(|source| ScopeError::ResolveTarget {
path: rule.target.clone(),
source,
})?;
Ok(ResolvedRule { Ok(ResolvedRule {
target, logical_target,
resolved_target,
permission: rule.permission, permission: rule.permission,
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
}) })
} }
/// Convert `path` to an absolute form suitable for prefix comparison. /// Resolve every existing path component while retaining a missing final tail.
/// /// A dangling symlink is rejected rather than treated as an ordinary missing
/// Tries `canonicalize` on the full path first (resolves symlinks). If /// component because its resolved authority cannot be established.
/// the path doesn't exist yet, climbs to the closest existing ancestor, fn resolve_path(path: &Path) -> std::io::Result<PathBuf> {
/// canonicalizes it, then rejoins the missing tail. Returns `None` for let mut cursor = path;
/// relative inputs that have no existing ancestor to anchor against. let mut missing = Vec::<OsString>::new();
fn resolve_path(path: &Path) -> Option<PathBuf> { 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() { if !path.is_absolute() {
return None; return None;
} }
if let Ok(canonical) = path.canonicalize() { let mut normalized = PathBuf::new();
return Some(canonical); for component in path.components() {
} match component {
let mut tail: Vec<OsString> = Vec::new(); Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
let mut cur = path.to_path_buf(); Component::RootDir => normalized.push(component.as_os_str()),
loop { Component::CurDir => {}
if let Ok(canonical) = cur.canonicalize() { Component::ParentDir => {
let mut out = canonical; if !normalized.pop() {
for segment in tail.iter().rev() {
out.push(segment);
}
return Some(out);
}
let name = cur.file_name()?.to_os_string();
tail.push(name);
let parent = cur.parent()?.to_path_buf();
if parent == cur {
return None; return None;
} }
cur = parent;
} }
Component::Normal(part) => normalized.push(part),
}
}
normalized.is_absolute().then_some(normalized)
} }
#[cfg(test)] #[cfg(test)]
@@ -577,6 +687,7 @@ mod tests {
target: target.to_path_buf(), target: target.to_path_buf(),
permission, permission,
recursive, recursive,
symlink_policy: Default::default(),
} }
} }
@@ -691,6 +802,7 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}; };
@@ -790,6 +902,7 @@ mod tests {
target: PathBuf::from("relative/path"), target: PathBuf::from("relative/path"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}; };
@@ -805,6 +918,88 @@ mod tests {
assert!(!scope.is_readable(&traversal)); 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] #[test]
fn summary_lists_readable_and_writable() { fn summary_lists_readable_and_writable() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -851,11 +1046,13 @@ mod tests {
target: docs.clone(), target: docs.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -914,6 +1111,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.unwrap(); .unwrap();
assert!(extended.is_readable(&extra.path().join("x"))); assert!(extended.is_readable(&extra.path().join("x")));
@@ -931,6 +1129,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.unwrap(); .unwrap();
let f = sub.join("a.txt"); let f = sub.join("a.txt");
@@ -950,6 +1149,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
let base = Scope::writable(dir.path()) let base = Scope::writable(dir.path())
.unwrap() .unwrap()
@@ -1003,6 +1203,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1021,6 +1222,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
+13 -5
View File
@@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidateStagingOperation { pub struct MemoryConsolidateStagingOperation {
#[serde(default)] #[serde(default)]
pub force: bool, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -450,10 +447,21 @@ mod tests {
use super::*; use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate}; 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] #[test]
fn staging_list_read_close_records_reason_and_deletes_candidate() { fn staging_list_read_close_records_reason_and_deletes_candidate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path()); let layout = WorkspaceLayout::resolve(temp.path());
let source = SourceRef { let source = SourceRef {
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
range: [0, 1], range: [0, 1],
+1 -2
View File
@@ -21,8 +21,7 @@ pub struct StagingEntry {
pub id: Uuid, pub id: Uuid,
pub path: PathBuf, pub path: PathBuf,
pub record: StagingRecord, pub record: StagingRecord,
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`) /// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
/// に使う。
pub bytes: u64, pub bytes: u64,
} }
+1
View File
@@ -23,6 +23,7 @@ fn deny_write(target: &Path) -> ScopeRule {
target: target.to_path_buf(), target: target.to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
+8 -33
View File
@@ -70,24 +70,12 @@ impl WorkspaceLayout {
Self { root: root.into() } 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 /// Resolution searches `default_root` and its ancestors for the nearest
/// explicit root, resolution searches `default_root` and its ancestors for /// `.yoi/memory` directory. This legacy local-storage helper owns its path
/// the nearest `.yoi/memory` directory. This keeps child worktrees that /// policy directly; resolved Worker Manifests do not carry storage paths.
/// contain `.yoi` project records such as tickets from pub fn resolve(default_root: &Path) -> Self {
/// 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());
}
let root = let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf()); find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root) Self::new(root)
@@ -335,16 +323,6 @@ mod tests {
assert!(matches!(err, LintError::InvalidPath(_))); 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] #[test]
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() { fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
let tmp = TempDir::new().unwrap(); 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(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(&child).unwrap(); std::fs::create_dir_all(&child).unwrap();
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), workspace.as_path()); 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(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap(); std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), workspace.as_path()); assert_eq!(layout.root(), workspace.as_path());
} }
@@ -381,8 +357,7 @@ mod tests {
assert_eq!(find_memory_marker_root(&child), None); assert_eq!(find_memory_marker_root(&child), None);
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), child.as_path()); assert_eq!(layout.root(), child.as_path());
} }
} }
+571 -103
View File
@@ -11,6 +11,11 @@ use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError}; 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 { fn default_true() -> bool {
true true
} }
@@ -27,21 +32,264 @@ fn is_false(value: &bool) -> bool {
// Method (Client → Worker via Unix Socket) // 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")] #[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum Method { 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>, input: Vec<Segment>,
}, },
/// Runtime-internal Run carrying an opaque correlation id that is committed /// Authenticated transport form of Submit. Trusted adapters replace
/// with the resulting UserInput entry. This variant is not serializable on /// public Submit before forwarding it to the Worker.
/// the public Client → Worker protocol.
#[serde(skip)]
#[cfg_attr(feature = "typescript", ts(skip))] #[cfg_attr(feature = "typescript", ts(skip))]
RunTracked { SubmitTracked {
submission_request_id: String,
input: Vec<Segment>, input: Vec<Segment>,
submission_id: String, #[serde(skip_deserializing, default)]
source: AuthenticatedInputSource,
}, },
/// Human-readable text injected into the target Worker's LLM context /// Human-readable text injected into the target Worker's LLM context
/// as a non-blocking system message. `auto_run` controls whether an /// 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 /// No side effects beyond LLM context; use `WorkerEvent` for typed
/// lifecycle reports. /// lifecycle reports.
Notify { Notify {
notification_request_id: String,
message: String, message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")] #[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool, 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. /// Typed lifecycle report from a child Worker to its direct parent.
WorkerEvent(WorkerEvent), WorkerEvent(WorkerEvent),
Resume, /// Return the authoritative FIFO summary without exposing queued payloads.
Cancel, 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`. /// Stop the in-flight turn and transition to `Paused`.
/// ///
/// Unlike `Cancel` (which discards and returns to `Idle`), a paused /// Unlike `Cancel` (which discards and returns to `Idle`), a paused
/// Worker can resume the interrupted work via `Resume`, or start a /// Worker can resume the interrupted work via `Resume`, or accept a
/// fresh turn via `Run` (orphan `tool_use` items are closed with a /// fresh `Submit` (orphan `tool_use` items are closed with a
/// synthetic tool result before the new user message is appended). /// synthetic tool result before the new user message is appended).
Pause, Pause {
command: WorkerCommandEnvelope,
},
/// Request an explicit compaction while the Worker is otherwise idle. /// Request an explicit compaction while the Worker is otherwise idle.
/// ///
/// This is a typed control method: clients must not send `compact` as a /// This is a typed control method: clients must not send `compact` as a
/// `Method::Run` user message. /// `Method::Submit` user message.
Compact, Compact {
command: WorkerCommandEnvelope,
},
/// Ask the Worker to list valid rewind targets from its authoritative session log. /// Ask the Worker to list valid rewind targets from its authoritative session log.
ListRewindTargets, ListRewindTargets,
/// Truncate the current session back to the selected rewind target and /// Truncate the current session back to the selected rewind target and
@@ -78,7 +363,9 @@ pub enum Method {
target: RewindTargetId, target: RewindTargetId,
expected_head_entries: usize, expected_head_entries: usize,
}, },
Shutdown, Shutdown {
command: WorkerCommandEnvelope,
},
/// Request a list of completion candidates from the Worker. /// Request a list of completion candidates from the Worker.
/// ///
/// Reply is sent on the same socket as `Event::Completions` (not /// Reply is sent on the same socket as `Event::Completions` (not
@@ -181,7 +468,7 @@ impl WorkerEvent {
/// One typed piece of a user submission. /// 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 /// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms /// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (paste chips, file refs) and /// (paste chips, file refs) and
@@ -404,12 +691,13 @@ impl Segment {
} }
impl Method { 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 /// Used by dumb clients, inter-Worker tools, and tests that only have
/// a string to forward. /// a string to forward.
pub fn run_text(s: impl Into<String>) -> Self { pub fn submit_text(submission_request_id: impl Into<String>, text: impl Into<String>) -> Self {
Self::Run { Self::Submit {
input: vec![Segment::text(s)], submission_request_id: submission_request_id.into(),
input: vec![Segment::text(text)],
} }
} }
} }
@@ -503,6 +791,39 @@ pub enum ToolResultDisposition {
OutcomeUnknown, 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. /// Canonical, storage-independent projection of committed session history.
/// ///
/// Worker protocols expose this DTO instead of append-log records. New /// Worker protocols expose this DTO instead of append-log records. New
@@ -511,6 +832,8 @@ pub enum ToolResultDisposition {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionSnapshot { pub struct SessionSnapshot {
#[serde(default)]
pub pending_submissions: PendingSubmissionsSnapshot,
pub entries: Vec<SessionSnapshotEntry>, pub entries: Vec<SessionSnapshotEntry>,
} }
@@ -609,16 +932,28 @@ pub struct SessionToolAttachment {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")] #[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event { pub enum Event {
/// A user input message was accepted, persisted as /// Durable Submit acceptance. A `Queued` receipt follows the durable FIFO
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn. /// checkpoint. A `Started` receipt follows the pending activation checkpoint;
/// Broadcast to every subscribed client so TUI / GUI instances show /// the corresponding [`Event::UserMessage`] marks the later history commit.
/// the same user line that reconnect snapshots would replay from /// Repeating the same request id and exact payload returns the same receipt
/// history; clients must not synthesize a separate pending/fake /// without appending or activating twice.
/// message for accepted runs. SubmissionAccepted {
/// submission_request_id: String,
/// Fires exactly once per committed user input, after submission_id: String,
/// `InvokeStart { kind: UserSend }` and before the first disposition: SubmissionDisposition,
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit. },
/// 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 { UserMessage {
segments: Vec<Segment>, segments: Vec<Segment>,
}, },
@@ -641,7 +976,7 @@ pub enum Event {
/// ///
/// Marker event for the start of an Invoke range; the range extends /// Marker event for the start of an Invoke range; the range extends
/// implicitly until the next `InvokeStart`. Fires for every accepted /// 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 /// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output, /// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
/// typed system reminder insertion that doesn't break IDLE) do not /// typed system reminder insertion that doesn't break IDLE) do not
@@ -798,8 +1133,9 @@ pub enum Event {
Snapshot { Snapshot {
session: SessionSnapshot, session: SessionSnapshot,
greeting: Greeting, greeting: Greeting,
#[serde(default)] /// Full revisioned live execution state. `Stopped` remains Runtime
status: WorkerStatus, /// catalog authority and is deliberately not represented here.
state: WorkerStateSnapshot,
/// Unfinished model output that has already streamed in the current /// Unfinished model output that has already streamed in the current
/// run but is not yet represented by committed snapshot entries. /// run but is not yet represented by committed snapshot entries.
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")] #[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
@@ -836,8 +1172,11 @@ pub enum Event {
}, },
/// Current Worker controller status. Broadcast on every controller-level /// Current Worker controller status. Broadcast on every controller-level
/// transition and included in `History` snapshots for late attach. /// transition and included in `History` snapshots for late attach.
Status { WorkerState {
status: WorkerStatus, snapshot: WorkerStateSnapshot,
},
CommandAcknowledged {
acknowledgement: WorkerCommandAcknowledgement,
}, },
/// Bounded, provider-owned command telemetry for the live Console. This is /// Bounded, provider-owned command telemetry for the live Console. This is
/// intentionally not a history entry and is reconstructed from /// intentionally not a history entry and is reconstructed from
@@ -1193,7 +1532,7 @@ pub enum TurnResult {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InvokeKind { pub enum InvokeKind {
/// `Method::Run` — a user submission. /// `Method::Submit` — a user submission.
UserSend, UserSend,
/// `Method::Notify` — free-text notification injected into history. /// `Method::Notify` — free-text notification injected into history.
Notify, Notify,
@@ -1216,7 +1555,7 @@ pub enum RunResult {
Finished, Finished,
Paused, Paused,
LimitReached, 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 /// 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 /// to its pre-submit snapshot. Clients should treat the Worker as Idle and
/// restore the just-submitted input into the editable composer if desired. /// restore the just-submitted input into the editable composer if desired.
@@ -1260,12 +1599,30 @@ pub struct ScopeRule {
/// direct children. Defaults to `true`. /// direct children. Defaults to `true`.
#[serde(default = "default_recursive")] #[serde(default = "default_recursive")]
pub recursive: bool, pub recursive: bool,
/// Which path identity an allow rule uses when symbolic links are
/// encountered. Deny rules always inspect both identities.
#[serde(default)]
pub symlink_policy: SymlinkPolicy,
} }
fn default_recursive() -> bool { fn default_recursive() -> bool {
true true
} }
/// Symbolic-link identity used by one filesystem allow rule.
///
/// `Resolved` is the least authority and the default: access is matched
/// against the provider-resolved target. `Logical` intentionally grants the
/// path as presented through the Workdir, even when it aliases another target.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum SymlinkPolicy {
#[default]
Resolved,
Logical,
}
/// Permission lattice used by [`ScopeRule`]. /// Permission lattice used by [`ScopeRule`].
/// ///
/// The derived `Ord` instance follows declaration order, so /// The derived `Ord` instance follows declaration order, so
@@ -1285,26 +1642,101 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn method_run_json_roundtrip() { fn scope_rule_defaults_to_resolved_symlink_policy() {
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"Hello"}]}}"#; 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(); let method: Method = serde_json::from_str(json).unwrap();
match &method { match &method {
Method::Run { input } => { Method::Submit { input, .. } => {
assert_eq!(input.len(), 1); assert_eq!(input.len(), 1);
match &input[0] { match &input[0] {
Segment::Text { content } => assert_eq!(content, "Hello"), Segment::Text { content } => assert_eq!(content, "Hello"),
other => panic!("expected Text, got {other:?}"), 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(); let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json); assert_eq!(serialized, json);
assert!(
serde_json::from_str::<Method>(r#"{"method":"run","params":{"input":[]}}"#).is_err()
);
} }
#[test] #[test]
fn method_run_paste_segment_roundtrip() { fn method_submit_paste_segment_roundtrip() {
let method = Method::Run { let method = Method::Submit {
submission_request_id: "request-1".to_string(),
input: vec![ input: vec![
Segment::text("see "), Segment::text("see "),
Segment::Paste { Segment::Paste {
@@ -1318,7 +1750,7 @@ mod tests {
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Method::Run { input } => { Method::Submit { input, .. } => {
assert_eq!(input.len(), 2); assert_eq!(input.len(), 2);
match &input[1] { match &input[1] {
Segment::Paste { Segment::Paste {
@@ -1335,7 +1767,7 @@ mod tests {
other => panic!("expected Paste, got {other:?}"), 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] #[test]
fn method_run_flow_segment_roundtrip() { fn method_submit_flow_segment_roundtrip() {
let method = Method::Run { let method = Method::Submit {
submission_request_id: "request-1".to_string(),
input: vec![ input: vec![
Segment::Flow { Segment::Flow {
selector: "builtin:coder-review".to_string(), selector: "builtin:coder-review".to_string(),
@@ -1404,7 +1837,7 @@ mod tests {
let decoded = serde_json::from_str::<Method>(&json).unwrap(); let decoded = serde_json::from_str::<Method>(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Method::Run { input } Method::Submit { input, .. }
if matches!( if matches!(
input.as_slice(), input.as_slice(),
[ [
@@ -1416,15 +1849,26 @@ mod tests {
} }
#[test] #[test]
fn runtime_tracked_run_is_not_public_protocol_json() { fn authenticated_submit_replaces_wire_source_with_transport_identity() {
let method = Method::RunTracked { let method = Method::SubmitTracked {
input: vec![Segment::text("private")], 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!( assert!(
serde_json::from_str::<Method>( serde_json::from_str::<Method>(
r#"{"method":"run_tracked","input":[],"submission_id":"forged"}"#, r#"{"method":"submit_tracked","input":[],"submission_request_id":"forged"}"#,
) )
.is_err() .is_err()
); );
@@ -1442,42 +1886,53 @@ mod tests {
} }
#[test] #[test]
fn method_run_with_unknown_segment_decodes() { fn method_submit_with_unknown_segment_decodes() {
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#; 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(); let method: Method = serde_json::from_str(json).unwrap();
match method { match method {
Method::Run { input } => { Method::Submit { input, .. } => {
assert_eq!(input.len(), 2); assert_eq!(input.len(), 2);
assert!(matches!(input[0], Segment::Text { .. })); assert!(matches!(input[0], Segment::Text { .. }));
assert!(matches!(input[1], Segment::Unknown)); assert!(matches!(input[1], Segment::Unknown));
} }
other => panic!("expected Run, got {other:?}"), other => panic!("expected Submit, got {other:?}"),
} }
} }
#[test] #[test]
fn method_without_params() { fn lifecycle_method_without_command_fails_closed() {
let json = r#"{"method":"resume"}"#; let error = serde_json::from_str::<Method>(r#"{"method":"resume"}"#).unwrap_err();
let method: Method = serde_json::from_str(json).unwrap(); assert!(error.to_string().contains("params"));
assert!(matches!(method, Method::Resume));
} }
#[test] #[test]
fn method_pause_roundtrip() { fn lifecycle_methods_roundtrip_with_fences() {
let json = r#"{"method":"pause"}"#; for method in [
let method: Method = serde_json::from_str(json).unwrap(); Method::Pause {
assert!(matches!(method, Method::Pause)); command: WorkerCommandEnvelope {
let serialized = serde_json::to_string(&method).unwrap(); command_id: 11,
assert_eq!(serialized, json); 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]
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);
} }
#[test] #[test]
@@ -1648,11 +2103,11 @@ mod tests {
#[test] #[test]
fn method_notify_json_roundtrip_defaults_to_auto_run() { 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(); let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!( assert!(matches!(
method, 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(); let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json); assert_eq!(serialized, json);
@@ -1660,11 +2115,11 @@ mod tests {
#[test] #[test]
fn method_notify_weak_json_roundtrip_serializes_auto_run_false() { 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(); let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!( assert!(matches!(
method, 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); assert_eq!(serde_json::to_string(&method).unwrap(), json);
} }
@@ -1725,6 +2180,7 @@ mod tests {
fn event_snapshot_format() { fn event_snapshot_format() {
let event = Event::Snapshot { let event = Event::Snapshot {
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: vec![SessionSnapshotEntry { entries: vec![SessionSnapshotEntry {
entry_id: "entry-1".into(), entry_id: "entry-1".into(),
timestamp: 1, timestamp: 1,
@@ -1745,7 +2201,7 @@ mod tests {
context_window: 200_000, context_window: 200_000,
context_tokens: 42_000, context_tokens: 42_000,
}, },
status: WorkerStatus::Paused, state: WorkerStatus::Paused.into(),
in_flight: InFlightSnapshot::default(), in_flight: InFlightSnapshot::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}; };
@@ -1762,12 +2218,13 @@ mod tests {
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read"); assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000); assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_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] #[test]
fn event_snapshot_in_flight_roundtrip_and_default() { fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"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(); let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded { match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()), Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@@ -1776,6 +2233,7 @@ mod tests {
let event = Event::Snapshot { let event = Event::Snapshot {
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: Greeting { greeting: Greeting {
@@ -1788,7 +2246,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Running, state: WorkerStatus::Running.into(),
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
blocks: vec![ blocks: vec![
InFlightBlock::Text { InFlightBlock::Text {
@@ -1844,6 +2302,7 @@ mod tests {
fn event_segment_rotated_roundtrip() { fn event_segment_rotated_roundtrip() {
let event = Event::SegmentRotated { let event = Event::SegmentRotated {
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
}; };
@@ -1875,20 +2334,32 @@ mod tests {
} }
#[test] #[test]
fn event_status_format() { fn event_worker_state_format() {
let event = Event::Status { let event = Event::WorkerState {
status: WorkerStatus::Running, 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 json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "status"); assert_eq!(parsed["event"], "worker_state");
assert_eq!(parsed["data"]["status"], "running"); 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(); let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Event::Status { Event::WorkerState {
status: WorkerStatus::Running snapshot: WorkerStateSnapshot {
execution_generation: 7,
revision: 3,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
..
}
} }
)); ));
} }
@@ -1929,19 +2400,10 @@ mod tests {
} }
#[test] #[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 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(); let error = serde_json::from_str::<Event>(json).unwrap_err();
match decoded { assert!(error.to_string().contains("state"));
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:?}"),
}
} }
#[test] #[test]
@@ -2037,6 +2499,7 @@ mod tests {
target: "/tmp/work".into(), target: "/tmp/work".into(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}); });
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
@@ -2354,7 +2817,12 @@ mod tests {
"scope_summary": "scope", "scope_summary": "scope",
"tools": [] "tools": []
}, },
"status": "idle" "state": {
"execution_generation": 1,
"revision": 0,
"last_command_id": 0,
"state": { "kind": "idle" }
}
} }
})) }))
.unwrap(); .unwrap();
+6
View File
@@ -573,6 +573,11 @@ pub struct SubscriptionWorker {
pub resource_key: Option<String>, pub resource_key: Option<String>,
/// Producer-owned monotonic revision for this Worker subject. /// Producer-owned monotonic revision for this Worker subject.
pub subject_revision: u64, 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, pub state: SubscriptionWorkerState,
#[serde(default)] #[serde(default)]
pub has_running_internal_workers: bool, pub has_running_internal_workers: bool,
@@ -874,6 +879,7 @@ mod tests {
runtime_id: None, runtime_id: None,
resource_key: None, resource_key: None,
subject_revision: 0, subject_revision: 0,
worker_state: None,
state: SubscriptionWorkerState::Idle, state: SubscriptionWorkerState::Idle,
has_running_internal_workers: false, has_running_internal_workers: false,
workspace_id: Some("workspace-1".to_string()), workspace_id: Some("workspace-1".to_string()),
+21 -5
View File
@@ -8,11 +8,14 @@ use crate::{
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType, InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
WorkerStatus, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -46,12 +49,22 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertSource>(&cfg, &mut output); push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output); push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<WorkerStatus>(&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::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output); push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output); push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ToolResultDisposition>(&cfg, &mut output); push_decl::<ToolResultDisposition>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output); push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output); push_decl::<Permission>(&cfg, &mut output);
push_decl::<SymlinkPolicy>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output); push_decl::<InFlightToolCallState>(&cfg, &mut output);
push_decl::<CommandStatus>(&cfg, &mut output); push_decl::<CommandStatus>(&cfg, &mut output);
push_decl::<CommandStream>(&cfg, &mut output); push_decl::<CommandStream>(&cfg, &mut output);
@@ -75,6 +88,9 @@ pub fn generated_protocol_types() -> String {
push_decl::<SessionToolAttachment>(&cfg, &mut output); push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output); push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&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::<SessionSnapshot>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output); push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output); push_decl::<InternalWorkerRef>(&cfg, &mut output);
+166 -4
View File
@@ -21,8 +21,10 @@ use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError}; use crate::store::{Store, StoreError};
use crate::uploaded_file::{ use crate::uploaded_file::{
bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files, bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files,
delete_uncommitted_uploaded_files, delete_uploaded_file, list_uploaded_file_refs, delete_uncommitted_uploaded_files, delete_uploaded_file, finalize_uploaded_file_binding,
read_uploaded_file, read_uploaded_file_by_id, write_uploaded_file, 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::{ use crate::{
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext, 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( fn delete_uploaded_file(
&self, &self,
session_id: SessionId, session_id: SessionId,
@@ -541,7 +598,13 @@ impl Store for FsStore {
let Some(source_entry_id) = reference.source_entry_id.as_deref() else { let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
continue; continue;
}; };
if !self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? { 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)?; clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
if delete_uploaded_file(&dir, &reference.artifact_id)? { if delete_uploaded_file(&dir, &reference.artifact_id)? {
removed = removed removed = removed
@@ -549,7 +612,6 @@ impl Store for FsStore {
.ok_or(StoreError::ArtifactQuotaExceeded)?; .ok_or(StoreError::ArtifactQuotaExceeded)?;
} }
} }
}
Ok(removed) Ok(removed)
} }
@@ -865,6 +927,106 @@ mod tests {
assert!(store.read_uploaded_file(owner, &reference).is_err()); 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] #[test]
fn uploaded_file_validation_and_shared_quota_fail_closed() { fn uploaded_file_validation_and_shared_quota_fail_closed() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
@@ -183,6 +183,7 @@ fn canonicalize_history_entry(
item, item,
metadata: legacy_metadata(segment_id, line_index, 0), metadata: legacy_metadata(segment_id, line_index, 0),
}, },
extensions: Vec::new(),
}, },
} }
} }
+5 -2
View File
@@ -71,7 +71,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
entries.push(history_entry(entry, *ts, data)); 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.item,
entry.metadata.entry_id.0.clone(), entry.metadata.entry_id.0.clone(),
*ts, *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( fn extend_history(
+1
View File
@@ -287,6 +287,7 @@ pub fn append_system_item(
LogEntry::AnnotatedSystemItem { LogEntry::AnnotatedSystemItem {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
entry, entry,
extensions: Vec::new(),
}, },
) )
} }
+10 -1
View File
@@ -112,6 +112,8 @@ pub enum LogEntry {
AnnotatedSystemItem { AnnotatedSystemItem {
ts: u64, ts: u64,
entry: LoggedSystemHistoryEntry, entry: LoggedSystemHistoryEntry,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
}, },
/// Turn boundary. Records the turn count after increment. /// 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.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone())); state.history.push(Item::from(entry.item.clone()));
} }
LogEntry::AnnotatedSystemItem { entry, .. } => { LogEntry::AnnotatedSystemItem {
entry, extensions, ..
} => {
state.annotated_history.push(LoggedHistoryEntry { state.annotated_history.push(LoggedHistoryEntry {
item: LoggedItem::from(entry.item.to_history_item()), item: LoggedItem::from(entry.item.to_history_item()),
metadata: entry.metadata.clone(), metadata: entry.metadata.clone(),
}); });
state.history.push(entry.item.to_history_item()); 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, .. } => { LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count { if let Some(active_turn_count) = &mut state.active_run_turn_count {
+41
View File
@@ -226,6 +226,47 @@ pub trait Store: Send + Sync {
Err(StoreError::PasteArtifactUnsupported) 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`. /// Delete an uncommitted uploaded file owned by `session_id`.
fn delete_uploaded_file( fn delete_uploaded_file(
&self, &self,
+143 -2
View File
@@ -24,6 +24,12 @@ pub const DEFAULT_MAX_FILES_PER_SUBMISSION: usize = 8;
pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256; pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256;
const MAX_FILE_NAME_CHARS: usize = 255; const MAX_FILE_NAME_CHARS: usize = 255;
const MAX_MEDIA_TYPE_BYTES: usize = 127; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UploadedFileLimits { pub struct UploadedFileLimits {
@@ -59,6 +65,8 @@ struct StoredUploadedFile {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
source_entry_id: Option<String>, source_entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[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>, upload_context: Option<UploadedFileUploadContext>,
content_base64: String, content_base64: String,
} }
@@ -291,6 +299,7 @@ pub(crate) fn write_uploaded_file(
byte_len, byte_len,
sha256: sha256.clone(), sha256: sha256.clone(),
source_entry_id: None, source_entry_id: None,
pending_owner_id: None,
upload_context: context.cloned(), upload_context: context.cloned(),
content_base64: BASE64.encode(content), content_base64: BASE64.encode(content),
}; };
@@ -338,6 +347,12 @@ pub(crate) fn read_uploaded_file_by_id(
Ok((reference, content)) 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>> { 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)?; let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
if stored_reference.file_name != reference.file_name if stored_reference.file_name != reference.file_name
@@ -376,6 +391,98 @@ pub(crate) fn clear_uploaded_file_binding(
Ok(()) 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( pub(crate) fn bind_uploaded_file(
dir: &Path, dir: &Path,
reference: &UploadedFileRef, reference: &UploadedFileRef,
@@ -479,6 +586,40 @@ pub(crate) fn copy_committed_uploaded_files(source_dir: &Path, target_dir: &Path
Ok(copied) 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> { pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
let aggregate_lock = fs::OpenOptions::new() let aggregate_lock = fs::OpenOptions::new()
@@ -499,7 +640,7 @@ pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
continue; continue;
} }
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?; 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)?; fs::remove_file(path)?;
removed = removed removed = removed
.checked_add(1) .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) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()), 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); return Err(StoreError::ArtifactAlreadyCommitted);
} }
match fs::remove_file(path) { match fs::remove_file(path) {
@@ -63,6 +63,8 @@ pub struct WorkerSpawnedScopeRule {
pub target: PathBuf, pub target: PathBuf,
pub permission: String, pub permission: String,
pub recursive: bool, pub recursive: bool,
#[serde(default)]
pub symlink_policy: protocol::SymlinkPolicy,
} }
/// One child Worker spawned by this Worker and persisted with the spawner's /// One child Worker spawned by this Worker and persisted with the spawner's
@@ -682,6 +684,25 @@ mod tests {
assert_eq!(restored, metadata); assert_eq!(restored, metadata);
} }
#[test]
fn spawned_scope_rule_defaults_resolved_and_roundtrips_logical_policy() {
let legacy: WorkerSpawnedScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace/src",
"permission": "read",
"recursive": true
}))
.unwrap();
assert_eq!(legacy.symlink_policy, protocol::SymlinkPolicy::Resolved);
let logical = WorkerSpawnedScopeRule {
symlink_policy: protocol::SymlinkPolicy::Logical,
..legacy
};
let restored: WorkerSpawnedScopeRule =
serde_json::from_value(serde_json::to_value(&logical).unwrap()).unwrap();
assert_eq!(restored, logical);
}
#[test] #[test]
fn worker_aggregate_store_writes_one_fixed_metadata_identity() { fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -835,6 +856,7 @@ mod tests {
target: std::path::Path::new("/tmp/delegated").into(), target: std::path::Path::new("/tmp/delegated").into(),
permission: "write".into(), permission: "write".into(),
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
store store
.set_spawned_children( .set_spawned_children(
+9 -5
View File
@@ -3,7 +3,7 @@ mod common;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::sync::Arc; 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::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::types::{Item, RequestConfig}; use agen::llm_client::types::{Item, RequestConfig};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
@@ -100,8 +100,11 @@ struct PausePolicy;
#[async_trait] #[async_trait]
impl Interceptor for PausePolicy { impl Interceptor for PausePolicy {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction { async fn on_assistant_turn_end(
TurnEndAction::Pause &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() { async fn session_resume_after_pause() {
let (_dir, store) = make_store(); 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 client = MockLlmClient::with_responses(tool_call_events());
let mut worker = TestWorker::new(Engine::new(client)); let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition()); worker.register_tool(weather_tool_definition());
@@ -386,7 +390,7 @@ async fn session_resume_after_pause() {
// Restore state and verify // Restore state and verify
let state = session_store::restore(&store, sid, segid).unwrap(); let state = session_store::restore(&store, sid, segid).unwrap();
assert!(state.last_run_interrupted); assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, Some(2)); assert_eq!(state.active_run_turn_count, Some(1));
} }
#[tokio::test] #[tokio::test]
+10 -2
View File
@@ -318,7 +318,11 @@ impl StandaloneHost {
} }
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> { 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 { let Some(shutdown) = self.shutdown.take() else {
self.retain_lease(); self.retain_lease();
return Err(StandaloneShutdownError::ConfirmationLost); return Err(StandaloneShutdownError::ConfirmationLost);
@@ -500,7 +504,11 @@ fn active_pointer(
} }
async fn stop_started_worker(started: BootstrappedWorker) { 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; let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
} }
+111 -6
View File
@@ -191,8 +191,7 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error) StandaloneStoreError::Io(error)
} }
})?; })?;
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes) let record = decode_worker_record(id, &bytes)?;
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
if record.schema_version > SCHEMA_VERSION { if record.schema_version > SCHEMA_VERSION {
return Err(StandaloneStoreError::NewerSchema { return Err(StandaloneStoreError::NewerSchema {
id, id,
@@ -408,7 +407,7 @@ impl StandaloneWorkerStore {
.create_new(true) .create_new(true)
.open(&temporary) .open(&temporary)
.map_err(StandaloneStoreError::Io)?; .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.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?; file.sync_all().map_err(StandaloneStoreError::Io)?;
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
@@ -428,8 +427,7 @@ impl StandaloneWorkerStore {
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let bytes = let bytes =
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes) decode_worker_record(id, &bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
} }
fn worker_dir(&self, id: WorkerId) -> PathBuf { 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> { fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
let duration = SystemTime::now() let duration = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -709,7 +751,70 @@ pub enum StandaloneStoreError {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() { fn lease_liveness_requires_positive_live_or_stale_evidence() {
+13 -3
View File
@@ -99,7 +99,10 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
let mut protocol_client = host.connect(); let mut protocol_client = host.connect();
protocol_client protocol_client
.send(&Method::run_text("read the probe")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"read the probe",
))
.await .await
.expect("submit input"); .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 worker_id = host.worker_id();
let mut protocol_client = host.connect(); let mut protocol_client = host.connect();
protocol_client protocol_client
.send(&Method::run_text("first request")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"first request",
))
.await?; .await?;
wait_for_run_end(&mut protocol_client).await?; wait_for_run_end(&mut protocol_client).await?;
protocol_client protocol_client
.send(&Method::Notify { .send(&Method::Notify {
notification_request_id: protocol::new_submission_request_id(),
message: "persisted notification".to_string(), message: "persisted notification".to_string(),
auto_run: true, auto_run: true,
}) })
@@ -394,7 +401,10 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
assert!(snapshot.contains("persisted notification"), "{snapshot}"); assert!(snapshot.contains("persisted notification"), "{snapshot}");
protocol_client protocol_client
.send(&Method::run_text("continue after restore")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"continue after restore",
))
.await?; .await?;
wait_for_run_end(&mut protocol_client).await?; wait_for_run_end(&mut protocol_client).await?;
let request = second_inspection let request = second_inspection
+3
View File
@@ -118,6 +118,7 @@ impl Tool for BashTool {
command: params.command, command: params.command,
timeout_secs, timeout_secs,
output_limit: INLINE_BYTE_BUDGET, output_limit: INLINE_BYTE_BUDGET,
cwd: None,
spill_dir: Some(self.output_dir.clone()), spill_dir: Some(self.output_dir.clone()),
tool_call_id: Some(call_id.clone()), tool_call_id: Some(call_id.clone()),
}) })
@@ -299,11 +300,13 @@ mod tests {
target: root.path().to_path_buf(), target: root.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: output.path().to_path_buf(), target: output.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
+1
View File
@@ -40,6 +40,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
let scope = Scope::from_config(&config).unwrap(); let scope = Scope::from_config(&config).unwrap();
let fs: WorkdirSessionHandle = let fs: WorkdirSessionHandle =
+1
View File
@@ -27,6 +27,7 @@ fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
target: spill.to_path_buf(), target: spill.to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
Scope::from_config(&config).unwrap() Scope::from_config(&config).unwrap()
} }
+282 -191
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use protocol::{ use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method, InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
RewindTarget, RunResult, Segment, WorkerStatus, RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
}; };
use crate::block::{ use crate::block::{
@@ -102,23 +102,6 @@ struct RollbackSubmitState {
turn_before: usize, 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 { struct ComposerInputHistory {
entries: VecDeque<Vec<Segment>>, entries: VecDeque<Vec<Segment>>,
browse: Option<ComposerInputHistoryBrowse>, browse: Option<ComposerInputHistoryBrowse>,
@@ -242,8 +225,10 @@ pub struct WorkerViewTab {
pub struct App { pub struct App {
pub worker_name: String, pub worker_name: String,
pub connected: bool, pub connected: bool,
/// Last controller status reported by the Worker. Drives the status line /// Latest authoritative revisioned live execution state.
/// and Ctrl-key routing; do not infer this solely from replayed history. pub worker_state: WorkerStateSnapshot,
next_command_id: u64,
/// Derived Runtime-catalog compatibility projection used by existing UI.
pub worker_status: WorkerStatus, pub worker_status: WorkerStatus,
/// True while the Worker is in `WorkerStatus::Running`. /// True while the Worker is in `WorkerStatus::Running`.
pub running: bool, pub running: bool,
@@ -272,7 +257,7 @@ pub struct App {
/// Current transient actionbar notice. Notices are local UI state only: /// Current transient actionbar notice. Notices are local UI state only:
/// they are never appended to transcript/session history or LLM context. /// they are never appended to transcript/session history or LLM context.
actionbar_notice: Option<ActionbarNotice>, 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, pub input: InputBuffer,
/// Separate command-line input. It is never submitted as a user message. /// Separate command-line input. It is never submitted as a user message.
pub command_input: InputBuffer, pub command_input: InputBuffer,
@@ -333,9 +318,8 @@ pub struct App {
/// Top entry index of the task pane's visible window. Clamped on /// Top entry index of the task pane's visible window. Clamped on
/// render so it never points past the end of the list. /// render so it never points past the end of the list.
pub task_pane_scroll: usize, pub task_pane_scroll: usize,
/// TUI-local FIFO of user inputs submitted while the Worker is already running. /// Authoritative WorkerSession FIFO summary received from snapshot/live events.
/// Entries have not been sent to the Worker yet, so they remain editable/cancellable locally. pending_submissions: protocol::PendingSubmissionsSnapshot,
queued_inputs: VecDeque<QueuedInput>,
/// TUI-local readline-style composer input history. This is intentionally /// TUI-local readline-style composer input history. This is intentionally
/// client-side only: recalled entries are plain drafts until submitted again. /// client-side only: recalled entries are plain drafts until submitted again.
input_history: ComposerInputHistory, input_history: ComposerInputHistory,
@@ -355,6 +339,8 @@ impl App {
Self { Self {
worker_name, worker_name,
connected: false, connected: false,
worker_state: WorkerStateSnapshot::initial(1),
next_command_id: 1,
worker_status: WorkerStatus::Idle, worker_status: WorkerStatus::Idle,
running: false, running: false,
paused: false, paused: false,
@@ -395,7 +381,7 @@ impl App {
text_selection: TextSelectionState::default(), text_selection: TextSelectionState::default(),
task_pane_open: false, task_pane_open: false,
task_pane_scroll: 0, task_pane_scroll: 0,
queued_inputs: VecDeque::new(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
input_history: ComposerInputHistory::new(), input_history: ComposerInputHistory::new(),
input_history_store: None, input_history_store: None,
pending_submit_rollback: None, pending_submit_rollback: None,
@@ -763,23 +749,40 @@ impl App {
if self.paused { if self.paused {
self.input_history.cancel_browse(); self.input_history.cancel_browse();
self.input.clear(); self.input.clear();
return Some(Method::Resume); let command = self.next_command_envelope();
return Some(Method::Resume { command });
} }
return None; return None;
} }
self.record_input_history(segments.clone()); 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(); self.input.clear();
Some(self.method_for_run(segments)) 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) { pub fn restore_unsent_run(&mut self, method: &Method) {
let Method::Run { input } = method else { let Method::Submit { input, .. } = method else {
return; return;
}; };
self.pending_submit_rollback = None; self.pending_submit_rollback = None;
@@ -787,8 +790,9 @@ impl App {
self.input.replace_with_segments(input); self.input.replace_with_segments(input);
self.completion = None; self.completion = None;
} else { } else {
self.queued_inputs self.push_error(
.push_front(QueuedInput::new(input.clone())); "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(), block_start: self.blocks.len(),
turn_before: self.turn_index, 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>) { fn record_input_history(&mut self, segments: Vec<Segment>) {
@@ -825,7 +832,7 @@ impl App {
} }
pub fn queued_input_count(&self) -> usize { pub fn queued_input_count(&self) -> usize {
self.queued_inputs.len() self.pending_submissions.submissions.len()
} }
#[cfg(test)] #[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> { pub fn next_queued_input_preview(&self) -> Option<&str> {
self.queued_inputs.front().map(QueuedInput::preview) self.pending_submissions
} .submissions
.first()
pub fn clear_queued_inputs(&mut self) -> usize { .map(|submission| submission.submission_id.as_str())
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))
} }
pub fn clear_actionbar_notice(&mut self) { 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> { pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) { if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
return None; return None;
} }
match event { match event {
Event::SubmissionAccepted { .. } => {}
Event::SubmissionRejected { message, .. } => self.push_error(message),
Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = pending;
}
Event::UserMessage { segments } => { Event::UserMessage { segments } => {
self.turn_index += 1; self.turn_index += 1;
self.blocks.push(Block::TurnHeader { self.blocks.push(Block::TurnHeader {
@@ -1148,18 +1180,14 @@ impl App {
self.assistant_streaming = false; self.assistant_streaming = false;
} }
Event::TurnStart { .. } => { Event::TurnStart { .. } => {
self.set_worker_status(WorkerStatus::Running);
self.run_requests += 1; self.run_requests += 1;
self.current_tool = None; self.current_tool = None;
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
self.assistant_streaming = false; self.assistant_streaming = false;
} }
Event::InvokeStart { .. } => { Event::InvokeStart { .. } => {}
self.set_worker_status(WorkerStatus::Running);
}
// UI consumers of per-attempt LlmCall semantics remain out of scope; // UI consumers of per-attempt LlmCall semantics remain out of scope;
// the run-level status starts at InvokeStart and TurnStart counts each // authoritative run state comes only from WorkerStateSnapshot.
// LLM request within that run.
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => { Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
} }
@@ -1366,15 +1394,7 @@ impl App {
output_tokens: self.run_output_tokens, output_tokens: self.run_output_tokens,
}); });
self.pending_submit_rollback = None; self.pending_submit_rollback = None;
self.reset_run_state(match result { self.reset_run_state();
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();
}
} }
} }
Event::CompactStart { .. } => { Event::CompactStart { .. } => {
@@ -1444,14 +1464,15 @@ impl App {
Event::Snapshot { Event::Snapshot {
session, session,
greeting, greeting,
status, state,
in_flight, in_flight,
internal_workers, internal_workers,
} => { } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.pending_submissions = session.pending_submissions.clone();
self.restore_snapshot(&session, greeting, in_flight); self.restore_snapshot(&session, greeting, in_flight);
self.replace_internal_worker_snapshots(internal_workers); self.replace_internal_worker_snapshots(internal_workers);
self.set_worker_status(status); self.apply_worker_state_snapshot(&state);
} }
Event::InternalWorker { Event::InternalWorker {
worker, worker,
@@ -1461,9 +1482,12 @@ impl App {
Event::InternalWorkerRemoved { worker, revision } => { Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision) self.remove_internal_worker(worker, revision)
} }
Event::Status { status } => { Event::WorkerState { snapshot } => {
self.rewind_refresh_fence = false; 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 // Command telemetry is an operational Web Console surface. The
// TUI continues to render the final Bash ToolResult from history. // TUI continues to render the final Bash ToolResult from history.
@@ -1503,7 +1527,7 @@ impl App {
}; };
self.completion = None; self.completion = None;
self.close_rewind_picker(); self.close_rewind_picker();
self.reset_run_state(self.worker_status); self.reset_run_state();
let mut message = if restored_composer { let mut message = if restored_composer {
format!( format!(
"Rewound session: discarded {} log entries; restored selected input to composer.", "Rewound session: discarded {} log entries; restored selected input to composer.",
@@ -1551,8 +1575,7 @@ impl App {
None None
} }
fn reset_run_state(&mut self, status: WorkerStatus) { fn reset_run_state(&mut self) {
self.set_worker_status(status);
self.run_requests = 0; self.run_requests = 0;
self.run_upload_tokens = 0; self.run_upload_tokens = 0;
self.run_output_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." "Rolled back empty assistant turn; no local submitted input was available to restore."
.to_owned() .to_owned()
}; };
self.reset_run_state(WorkerStatus::Idle); self.reset_run_state();
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Warn, level: AlertLevel::Warn,
source: AlertSource::Worker, source: AlertSource::Worker,
@@ -2026,12 +2049,18 @@ impl App {
self.input_mode = CommandInputMode::Composer; self.input_mode = CommandInputMode::Composer;
self.command_completion_selected = None; 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.completion = None;
self.rewind_picker = None; self.rewind_picker = None;
self.rewind_request_pending = true; self.rewind_request_pending = true;
} }
result.method method
} }
fn push_command_diagnostic(&mut self, message: impl Into<String>) { fn push_command_diagnostic(&mut self, message: impl Into<String>) {
@@ -2681,7 +2710,10 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { 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")], input: vec![Segment::text("selected rewind input")],
summary: summary(3), summary: summary(3),
}); });
@@ -2700,7 +2732,10 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { 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")], input: vec![Segment::text("rewound input")],
summary: summary(1), summary: summary(1),
}); });
@@ -2743,7 +2778,10 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { 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")], input: vec![Segment::text("rewound input")],
summary: summary(2), summary: summary(2),
}); });
@@ -2752,8 +2790,8 @@ mod rewind_refresh_tests {
}); });
assert!(!blocks_contain(&app, "stale tail after rewind")); assert!(!blocks_contain(&app, "stale tail after rewind"));
app.handle_worker_event(Event::Status { app.handle_worker_event(Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}); });
app.handle_worker_event(Event::TextDelta { app.handle_worker_event(Event::TextDelta {
text: "new live tail after status".into(), text: "new live tail after status".into(),
@@ -2877,7 +2915,7 @@ mod composer_history_persistence_tests {
path: "src/lib.rs".into(), 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); let mut reloaded = App::new_with_input_history_store("test".into(), store);
assert!(reloaded.browse_input_history_older()); assert!(reloaded.browse_input_history_older());
@@ -2958,7 +2996,7 @@ mod composer_history_persistence_tests {
app.insert_char(c); app.insert_char(c);
} }
match app.submit_input() { match app.submit_input() {
Some(Method::Run { input }) => input, Some(Method::Submit { input, .. }) => input,
other => panic!("expected Run, got {other:?}"), other => panic!("expected Run, got {other:?}"),
} }
} }
@@ -3424,72 +3462,44 @@ mod completion_flow_tests {
} }
#[test] #[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()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "queued turn"); 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!(matches!(method, Some(Method::Submit { .. })));
assert_eq!(app.next_queued_input_preview(), Some("queued turn")); assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
} }
#[test] #[test]
fn finished_run_auto_sends_next_queued_input() { fn pending_submission_projection_is_worker_authoritative() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.handle_worker_event(Event::PendingSubmissionsChanged {
insert_text(&mut app, "next turn"); pending: protocol::PendingSubmissionsSnapshot {
assert!(app.submit_input().is_none()); revision: 3,
notification_count: 0,
let method = app.handle_worker_event(Event::RunEnd { head_id: Some("submission-1".into()),
result: RunResult::Finished, 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.queued_input_count(), 1);
assert_eq!(app.next_queued_input_preview(), Some("held turn")); 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] #[test]
@@ -3497,25 +3507,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Paused); app.set_worker_status(WorkerStatus::Paused);
assert!(matches!(app.submit_input(), Some(Method::Resume))); 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_eq!(app.queued_input_count(), 0); assert_eq!(app.queued_input_count(), 0);
} }
@@ -3530,7 +3522,7 @@ mod completion_flow_tests {
app.insert_char(c); app.insert_char(c);
} }
match app.submit_input() { match app.submit_input() {
Some(Method::Run { input }) => input, Some(Method::Submit { input, .. }) => input,
other => panic!("expected Run, got {other:?}"), other => panic!("expected Run, got {other:?}"),
} }
} }
@@ -3570,7 +3562,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: public_session(vec![session_start_value]), session: public_session(vec![session_start_value]),
status: WorkerStatus::Running, state: test_worker_state(WorkerStatus::Running),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -3581,6 +3573,90 @@ mod completion_flow_tests {
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_)))); 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] #[test]
fn snapshot_replaces_live_error_with_one_durable_run_error_block() { fn snapshot_replaces_live_error_with_one_durable_run_error_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -3588,8 +3664,8 @@ mod completion_flow_tests {
code: ErrorCode::ProviderError, code: ErrorCode::ProviderError,
message: "provider unavailable".into(), message: "provider unavailable".into(),
}); });
app.handle_worker_event(Event::Status { app.handle_worker_event(Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}); });
let live_errors = app let live_errors = app
@@ -3614,7 +3690,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: public_session(vec![serde_json::to_value(run_errored).unwrap()]), session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -3675,9 +3751,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Running, state: test_worker_state(WorkerStatus::Running),
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
blocks: vec![ blocks: vec![
InFlightBlock::Thinking { InFlightBlock::Thinking {
@@ -3783,6 +3860,7 @@ mod completion_flow_tests {
revision, revision,
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
in_flight: protocol::InFlightSnapshot::default(), in_flight: protocol::InFlightSnapshot::default(),
@@ -4000,9 +4078,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -4051,9 +4130,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: vec![InternalWorkerSnapshot { internal_workers: vec![InternalWorkerSnapshot {
worker: InternalWorkerRef { worker: InternalWorkerRef {
@@ -4064,6 +4144,7 @@ mod completion_flow_tests {
}, },
revision: 4, revision: 4,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Running, status: WorkerStatus::Running,
@@ -4200,6 +4281,13 @@ mod completion_flow_tests {
.count() .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 { fn test_greeting() -> protocol::Greeting {
protocol::Greeting { protocol::Greeting {
worker_name: "test".into(), worker_name: "test".into(),
@@ -4222,10 +4310,11 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting, greeting,
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -4424,7 +4513,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: public_session(assistant_item_entries), session: public_session(assistant_item_entries),
status: WorkerStatus::Running, state: test_worker_state(WorkerStatus::Running),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -4437,23 +4526,23 @@ mod completion_flow_tests {
} }
#[test] #[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()); let mut app = App::new("test".into());
app.running = true; app.running = true;
for c in "repeat".chars() { for c in "repeat".chars() {
app.insert_char(c); 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.input_history_len(), 1);
assert_eq!(app.queued_input_count(), 1); assert_eq!(app.queued_input_count(), 0);
for c in "repeat".chars() { for c in "repeat".chars() {
app.insert_char(c); 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.input_history_len(), 1);
assert_eq!(app.queued_input_count(), 2); assert_eq!(app.queued_input_count(), 0);
app.insert_char(' '); app.insert_char(' ');
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_none());
@@ -4481,7 +4570,7 @@ mod completion_flow_tests {
}, },
]; ];
app.input.replace_with_segments(&original); 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!(app.browse_input_history_older());
assert_eq!(app.input.submit_segments(), original); assert_eq!(app.input.submit_segments(), original);
@@ -4493,7 +4582,7 @@ mod completion_flow_tests {
for c in "sent".chars() { for c in "sent".chars() {
app.insert_char(c); 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() { for c in "draft".chars() {
app.insert_char(c); app.insert_char(c);
@@ -4511,7 +4600,7 @@ mod completion_flow_tests {
for c in "sent".chars() { for c in "sent".chars() {
app.insert_char(c); 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());
assert!(app.input_history_is_browsing()); assert!(app.input_history_is_browsing());
@@ -4528,17 +4617,19 @@ mod completion_flow_tests {
for c in "first".chars() { for c in "first".chars() {
app.insert_char(c); 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() { for c in "second".chars() {
app.insert_char(c); 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());
assert!(app.browse_input_history_older()); assert!(app.browse_input_history_older());
let method = app.submit_input(); let method = app.submit_input();
match method { 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:?}"), other => panic!("expected recalled run, got {other:?}"),
} }
assert_eq!(app.input_history_len(), 3); assert_eq!(app.input_history_len(), 3);
+483
View File
@@ -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());
}
}
+268 -40
View File
@@ -3,8 +3,9 @@ use std::io;
use std::time::Duration; use std::time::Duration;
use client::{ use client::{
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers, BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
list_backend_workers, restore_backend_worker, BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
}; };
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Frame; use ratatui::Frame;
@@ -12,6 +13,7 @@ use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;
use crate::backend_workspace_picker::select_backend_workspace; use crate::backend_workspace_picker::select_backend_workspace;
use crate::console; use crate::console;
@@ -83,17 +85,20 @@ pub(crate) async fn run(
let restore_target = target let restore_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?; .map_err(|error| io::Error::other(error.to_string()))?;
restore_backend_worker(&restore_target) let restore = restore_backend_worker(&restore_target)
.await .await
.map_err(|error| { .map_err(|error| {
io::Error::other(format!( io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}", "failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id selected.runtime_id, selected.worker_id
)) ))
})?;
restored_worker(restore).map_err(|error| {
io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})? })?
.result
.worker
.unwrap_or(selected)
} else { } else {
selected 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>) { fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone()))); workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
@@ -235,9 +267,10 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
layout[0], layout[0],
); );
let column_widths = WorkerColumnWidths::from_workers(&state.workers);
for (i, worker) in state.workers.iter().enumerate() { for (i, worker) in state.workers.iter().enumerate() {
frame.render_widget( frame.render_widget(
Paragraph::new(row_line(worker, i == state.selected)), Paragraph::new(row_line(worker, &column_widths, i == state.selected)),
layout[i + 1], layout[i + 1],
); );
} }
@@ -272,7 +305,28 @@ fn picker_title(target: &BackendRuntimeListTarget) -> String {
format!("backend workers workspace: {workspace} runtime: {runtime}") 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 marker = if selected { "" } else { " " };
let id_style = if selected { let id_style = if selected {
Style::default() Style::default()
@@ -281,42 +335,70 @@ fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
} else { } else {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
}; };
let preview_style = if selected { let name_style = if selected {
Style::default().fg(Color::White) Style::default().fg(Color::White)
} else { } else {
Style::default().fg(Color::DarkGray) 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![ Line::from(vec![
Span::raw(marker), Span::raw(marker),
Span::styled(short_worker_id(worker), id_style),
Span::raw(" "),
Span::styled( Span::styled(
format!("[{}]", worker.state), pad_column(&short_worker_id(worker), widths.identity),
state_style(worker.state.as_str()), id_style,
), ),
Span::raw(" "), Span::raw(" "),
Span::styled(pad_column(worker_name(worker), widths.name), name_style),
Span::raw(" "),
Span::styled( Span::styled(
format!("profile:{profile}"), pad_column(&worker_state(worker), widths.state),
Style::default().fg(Color::DarkGray), state_style(worker_state_label(worker)),
), ),
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
working_directory_text(worker), working_directory_text(worker),
Style::default().fg(Color::DarkGray), 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 { fn state_style(state: &str) -> Style {
match state { match state {
"running" | "idle" | "active" => Style::default() "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 { let Some(wd) = worker.working_directory.as_ref() else {
return "wd:—".to_string(); return "wd:—".to_string();
}; };
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown"); format!("wd:{}{}", wd.repository_key, wd.working_directory_id)
format!(
"wd:{}:{} {} {}",
wd.repository_key, wd.working_directory_id, wd.status, cleanliness
)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use client::{ use client::{
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
BackendWorkerWorkspaceSummary, BackendWorkerWorkspaceSummary,
}; };
@@ -378,7 +457,15 @@ mod tests {
identity: "ws".to_string(), identity: "ws".to_string(),
workspace_id: Some("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, last_seen_at: None,
pinned: false, pinned: false,
retention_state: String::new(), retention_state: String::new(),
@@ -395,18 +482,159 @@ mod tests {
} }
} }
#[test] fn row_text(worker: &BackendWorkerSummary, widths: &WorkerColumnWidths) -> String {
fn worker_row_matches_inline_picker_shape() { row_line(worker, widths, false)
let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true);
let text = row
.spans .spans
.into_iter() .into_iter()
.map(|span| span.content) .map(|span| span.content)
.collect::<String>(); .collect()
assert!(text.starts_with("▶ W-1")); }
assert!(text.contains("[running]"));
assert!(text.contains("profile:default")); fn display_column(text: &str, value: &str) -> usize {
assert!(text.contains("wd:—")); 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] #[test]
+5 -5
View File
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
println!("Repository path/URI is required."); println!("Repository path/URI is required.");
return Ok(None); return Ok(None);
} }
let repository_name = prompt_line("Repository display name [Main]: ")?; let repository_key = prompt_line("Repository key [main]: ")?;
let default_ref = prompt_line("Default ref [repository default]: ")?; let default_ref = prompt_line("Default ref [repository default]: ")?;
let operation_key = format!( let operation_key = format!(
"tui-workspace-create-{}-{}", "tui-workspace-create-{}-{}",
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
display_name, display_name,
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
uri, uri,
display_name: Some(if repository_name.is_empty() { repository_key: if repository_key.is_empty() {
"Main".to_string() "main".to_string()
} else { } else {
repository_name repository_key
}), },
default_ref: (!default_ref.is_empty()).then_some(default_ref), default_ref: (!default_ref.is_empty()).then_some(default_ref),
}, },
})) }))
+7 -2
View File
@@ -409,7 +409,12 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment; let _ = invocation.environment;
let _ = invocation.args.raw(); let _ = invocation.args.raw();
CommandExecution { 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")], diagnostics: vec![CommandDiagnostic::new("compact requested")],
exit_command_mode: true, exit_command_mode: true,
clear_input: true, clear_input: true,
@@ -483,7 +488,7 @@ mod tests {
fn compact_command_returns_compact_method_not_run() { fn compact_command_returns_compact_method_not_run() {
let registry = CommandRegistry::builtins(); let registry = CommandRegistry::builtins();
let result = registry.dispatch("compact", &env()); 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.exit_command_mode);
assert!(result.clear_input); assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("compact requested")); assert!(result.diagnostics[0].message.contains("compact requested"));
+176 -127
View File
@@ -270,8 +270,8 @@ impl<T: Socket> ConsoleConnection<T> {
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> { async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
let mut prepared = method.clone(); let mut prepared = method.clone();
let carries_attachments = let carries_attachments =
matches!(prepared, Method::Run { .. }) && !self.pending_attachments.is_empty(); matches!(prepared, Method::Submit { .. }) && !self.pending_attachments.is_empty();
if let Method::Run { input } = &mut prepared { if let Method::Submit { input, .. } = &mut prepared {
input.extend( input.extend(
self.pending_attachments self.pending_attachments
.iter() .iter()
@@ -569,9 +569,10 @@ async fn run_e2e_rewind_fixture(
app.connected = true; app.connected = true;
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Idle, state: WorkerStatus::Idle.into(),
greeting: Greeting { greeting: Greeting {
worker_name: worker_name.clone(), worker_name: worker_name.clone(),
cwd: workspace_root.display().to_string(), cwd: workspace_root.display().to_string(),
@@ -697,6 +698,7 @@ async fn run_e2e_rewind_fixture(
if submitted_at.elapsed() >= apply_delay { if submitted_at.elapsed() >= apply_delay {
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
input: vec![Segment::text("rewind-live-refresh")], 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> { fn attachment_command_path(method: &Method) -> Option<PathBuf> {
let Method::Run { input } = method else { let Method::Submit { input, .. } = method else {
return None; return None;
}; };
let [Segment::Text { content }] = input.as_slice() else { 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 { fn is_clear_attachments_command(method: &Method) -> bool {
let Method::Run { input } = method else { let Method::Submit { input, .. } = method else {
return false; return false;
}; };
matches!( matches!(
@@ -941,7 +943,7 @@ async fn send_console_method<T: Socket>(
client: &mut ConsoleConnection<T>, client: &mut ConsoleConnection<T>,
method: &Method, method: &Method,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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.restore_unsent_run(method);
app.flash_actionbar_notice( app.flash_actionbar_notice(
"Attachment upload is still in progress; wait or use /clear-attachments.", "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 = 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 let Err(error) = client.send(method).await {
if sends_attachments { if sends_attachments {
app.restore_unsent_run(method); app.restore_unsent_run(method);
@@ -1148,18 +1150,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.clear_command_input(); app.clear_command_input();
Some(None) 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) KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() => if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
{ {
if app.restore_next_queued_input_to_composer() { Some(app.continue_pending_method())
Some(app.refresh_completion())
} else {
Some(None)
}
} }
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => { KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
app.clear_queued_inputs(); Some(Some(app.clear_pending_method()))
Some(None)
} }
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)), KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(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 WorkerStatus::Running | WorkerStatus::Paused
) { ) {
app.shutdown_confirm = None; app.shutdown_confirm = None;
app.clear_queued_inputs(); let command = app.next_command_envelope();
return Some(Method::Cancel); return Some(Method::Cancel { command });
} }
if let Some(pressed_at) = app.shutdown_confirm if let Some(pressed_at) = app.shutdown_confirm
&& pressed_at.elapsed() < CONFIRM_TIMEOUT && pressed_at.elapsed() < CONFIRM_TIMEOUT
{ {
app.shutdown_confirm = None; 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.shutdown_confirm = Some(std::time::Instant::now());
app.flash_actionbar_notice( 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). /// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> { fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.worker_status == WorkerStatus::Running { if app.worker_status == WorkerStatus::Running {
app.clear_queued_inputs(); let command = app.next_command_envelope();
return Some(Method::Pause); return Some(Method::Pause { command });
} }
if let Some(t) = app.quit_confirm if let Some(t) = app.quit_confirm
&& t.elapsed() < CONFIRM_TIMEOUT && t.elapsed() < CONFIRM_TIMEOUT
@@ -1476,8 +1488,8 @@ mod tests {
use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::text_selection::{HistoryViewport, SelectionRow};
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{ use protocol::{
Event, RewindTarget, RewindTargetId, RunResult, Segment, UploadedFileAvailability, Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
UploadedFileRef, WorkerStatus, WorkerStatus,
}; };
#[test] #[test]
@@ -1490,7 +1502,8 @@ mod tests {
#[test] #[test]
fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() { 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")], input: vec![Segment::text("/attach /tmp/report.md")],
}; };
assert_eq!( assert_eq!(
@@ -1499,7 +1512,8 @@ mod tests {
); );
assert!(!is_clear_attachments_command(&attach)); 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")], input: vec![Segment::text("/clear-attachments")],
}; };
assert!(is_clear_attachments_command(&clear)); assert!(is_clear_attachments_command(&clear));
@@ -1605,7 +1619,7 @@ mod tests {
} }
#[tokio::test] #[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 { let file = UploadedFileRef {
artifact_id: "artifact-queued".into(), artifact_id: "artifact-queued".into(),
file_name: "queued.txt".into(), file_name: "queued.txt".into(),
@@ -1631,13 +1645,10 @@ mod tests {
let mut app = App::new("worker".into()); let mut app = App::new("worker".into());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
app.input.insert_str("queued inspect"); app.input.insert_str("queued inspect");
assert!(app.submit_input().is_none());
let method = app let method = app
.handle_worker_event(Event::RunEnd { .submit_input()
result: RunResult::Finished, .expect("running Submit is sent immediately");
})
.expect("queued run must be released");
send_console_method(&mut app, &mut connection, &method) send_console_method(&mut app, &mut connection, &method)
.await .await
.unwrap(); .unwrap();
@@ -1960,7 +1971,7 @@ mod tests {
} }
#[test] #[test]
fn running_enter_queues_instead_of_sending_run() { fn running_enter_sends_submit_to_worker() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() { 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.queued_input_count(), 0);
assert_eq!(app.next_queued_input_preview(), Some("queued"));
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
} }
#[test] #[test]
fn queued_input_keybindings_restore_and_clear() { fn running_alt_n_sends_explicit_notify_without_implicit_submit_conversion() {
let mut app = App::new("agent".to_string()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "edit queued".chars() { for character in "progress".chars() {
assert!( app.insert_char(character);
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!( let method = handle_key(
handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT) KeyEvent::new(KeyCode::Char('n'), KeyModifiers::ALT),
)
.is_none()
); );
assert_eq!(app.queued_input_count(), 0); assert!(matches!(
assert_eq!(input_text(&app), "edit queued"); method,
Some(Method::Notify {
app.input.clear(); ref message,
for c in "clear queued".chars() { auto_run: true,
assert!( ..
handle_key( }) if message == "progress"
&mut app, ));
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) assert_eq!(input_text(&app), "");
)
.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);
} }
#[test] #[test]
fn pause_and_cancel_clear_queued_input() { fn pending_queue_shortcuts_send_worker_operations() {
let mut app = App::new("agent".to_string()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.handle_worker_event(Event::PendingSubmissionsChanged {
for c in "queued".chars() { pending: protocol::PendingSubmissionsSnapshot {
assert!( revision: 2,
handle_key( notification_count: 0,
&mut app, head_id: Some("submission-1".into()),
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) submissions: vec![protocol::PendingSubmissionSummary {
) submission_id: "submission-1".into(),
.is_none() accepted_at_ms: 1,
); segment_count: 1,
} byte_len: 6,
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); }],
assert_eq!(app.queued_input_count(), 1); },
});
let pause = handle_key( let continue_next = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT),
); );
assert!(matches!(pause, Some(Method::Pause))); assert!(matches!(
assert_eq!(app.queued_input_count(), 0); continue_next,
Some(Method::ContinuePending {
for c in "queued again".chars() { expected_revision: 2,
assert!( ref expected_head_id,
handle_key( }) if expected_head_id == "submission-1"
&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_eq!(app.queued_input_count(), 1);
let cancel = handle_key( let cancel = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT),
); );
assert!(matches!(cancel, Some(Method::Cancel))); assert!(matches!(
assert_eq!(app.queued_input_count(), 0); 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] #[test]
@@ -2080,7 +2117,7 @@ mod tests {
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
); );
assert!(matches!(cancel, Some(Method::Cancel))); assert!(matches!(cancel, Some(Method::Cancel { .. })));
} }
#[test] #[test]
@@ -2102,7 +2139,7 @@ mod tests {
assert!(matches!( assert!(matches!(
handle_key(&mut app, ctrl_x()), handle_key(&mut app, ctrl_x()),
Some(Method::Shutdown) Some(Method::Shutdown { .. })
)); ));
assert!(app.shutdown_confirm.is_none()); assert!(app.shutdown_confirm.is_none());
} }
@@ -2432,7 +2469,7 @@ mod tests {
} }
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); 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!(!app.is_command_mode());
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
assert_eq!(app.queued_input_count(), 0); assert_eq!(app.queued_input_count(), 0);
@@ -2535,13 +2572,19 @@ mod tests {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
status: WorkerStatus::Idle, pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
app.handle_worker_event(Event::RewindApplied { 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 { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
}], }],
@@ -2562,15 +2605,21 @@ mod tests {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
status: WorkerStatus::Idle, pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
type_keys(&mut app, "draft"); type_keys(&mut app, "draft");
app.handle_worker_event(Event::RewindApplied { 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 { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
}], }],
@@ -2697,8 +2746,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
enter_command_mode(&mut app); enter_command_mode(&mut app);
@@ -2813,8 +2862,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
@@ -2839,8 +2888,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
handle_key(&mut app, key(KeyCode::Tab)); handle_key(&mut app, key(KeyCode::Tab));
@@ -2856,7 +2905,7 @@ mod tests {
); );
assert!(first.is_none()); assert!(first.is_none());
assert!(matches!(second, Some(Method::Shutdown))); assert!(matches!(second, Some(Method::Shutdown { .. })));
assert_eq!(app.worker_status, WorkerStatus::Idle); assert_eq!(app.worker_status, WorkerStatus::Idle);
} }
@@ -2878,8 +2927,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
@@ -2918,12 +2967,12 @@ mod tests {
type_keys(&mut app, "first"); type_keys(&mut app, "first");
assert!(matches!( assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)), handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. }) Some(Method::Submit { .. })
)); ));
type_keys(&mut app, "second"); type_keys(&mut app, "second");
assert!(matches!( assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)), handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. }) Some(Method::Submit { .. })
)); ));
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
@@ -2954,7 +3003,7 @@ mod tests {
type_keys(&mut app, "sent"); type_keys(&mut app, "sent");
assert!(matches!( assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)), handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. }) Some(Method::Submit { .. })
)); ));
type_keys(&mut app, "draft\nbody"); type_keys(&mut app, "draft\nbody");
app.move_cursor_start(); app.move_cursor_start();
+7
View File
@@ -1,5 +1,6 @@
mod app; mod app;
mod backend_dashboard; mod backend_dashboard;
mod backend_spawn;
mod backend_worker_picker; mod backend_worker_picker;
mod backend_workspace_picker; mod backend_workspace_picker;
mod block; mod block;
@@ -51,6 +52,8 @@ pub enum LaunchMode {
/// Restore one client-owned standalone Worker. The current cwd is the default scope; /// 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. /// `include_all` opts into all standalone Workers under the same client data root.
StandaloneResume { include_all: bool }, StandaloneResume { include_all: bool },
/// Create one Backend Worker and attach to it.
BackendSpawn,
/// List Backend Workers and attach to the selected Worker. /// List Backend Workers and attach to the selected Worker.
Workers { Workers {
runtime_id: Option<String>, 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>), 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 { LaunchMode::Workers {
runtime_id, runtime_id,
include_stopped, include_stopped,
+1 -6
View File
@@ -228,7 +228,7 @@ worker_context_max_tokens = 100000
enabled = true enabled = true
[feature.memory] [feature.memory]
enabled = true enabled = false
[feature.web] [feature.web]
enabled = true enabled = true
@@ -241,11 +241,6 @@ enabled = true
authoring = true authoring = true
thread = true thread = true
[memory]
extract_threshold = 50000
consolidation_threshold_files = 5
consolidation_threshold_bytes = 50000
[web] [web]
enabled = true enabled = true
+22 -14
View File
@@ -1880,7 +1880,7 @@ fn actionbar_left_item(app: &App, now: Instant) -> Option<(String, Style)> {
} }
if app.queued_input_count() > 0 { if app.queued_input_count() > 0 {
return Some(( 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), Style::default().fg(Color::DarkGray),
)); ));
} }
@@ -2136,9 +2136,25 @@ mod tests {
use super::*; use super::*;
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::block::{ToolCallBlock, ToolCallState}; use crate::block::{ToolCallBlock, ToolCallState};
use protocol::WorkerStatus; use protocol::Event;
use std::time::{Duration, Instant}; 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] #[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() { fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now(); let now = Instant::now();
@@ -2251,15 +2267,11 @@ mod tests {
#[test] #[test]
fn queue_status_text_includes_count_and_preview() { fn queue_status_text_includes_count_and_preview() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); set_pending_submission(&mut app, "submission-1");
for c in "queued preview".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
assert_eq!( assert_eq!(
queue_status_text(&app), 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()) Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into())
); );
app.set_worker_status(WorkerStatus::Running); set_pending_submission(&mut app, "submission-1");
for c in "queued turn".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
assert_eq!( assert_eq!(
actionbar_left_item(&app, now).map(|(text, _)| text), 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(); app.enter_command_mode();
+1
View File
@@ -14,6 +14,7 @@ fs-operation.workspace = true
manifest.workspace = true manifest.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true
File diff suppressed because it is too large Load Diff
+55 -43
View File
@@ -11,7 +11,8 @@ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId, ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
WorkdirSessionCapabilities, WriteRequest, WriteResult, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
WriteRequest, WriteResult,
}; };
/// Opaque Runtime-owned identifier for one ephemeral Workdir session. /// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -55,6 +56,8 @@ pub struct OpenWorkdirSessionResponse {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "request", rename_all = "snake_case")] #[serde(tag = "operation", content = "request", rename_all = "snake_case")]
pub enum WorkdirSessionOperation { pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
Stat(StatRequest), Stat(StatRequest),
Read(ReadRequest), Read(ReadRequest),
Write(WriteRequest), Write(WriteRequest),
@@ -68,12 +71,10 @@ pub enum WorkdirSessionOperation {
CommandCancel(CommandHandle), 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkdirSessionOperationRequest { pub struct WorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<crate::WorkdirDelegationRequest>,
pub operation: WorkdirSessionOperation, pub operation: WorkdirSessionOperation,
} }
@@ -81,6 +82,8 @@ pub struct WorkdirSessionOperationRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "result", rename_all = "snake_case")] #[serde(tag = "operation", content = "result", rename_all = "snake_case")]
pub enum WorkdirSessionOperationResult { pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
Stat(StatResult), Stat(StatResult),
Read(ReadResult), Read(ReadResult),
Write(WriteResult), Write(WriteResult),
@@ -289,13 +292,18 @@ mod client {
use reqwest::{Client, StatusCode, Url}; use reqwest::{Client, StatusCode, Url};
use super::*; use super::*;
use crate::{Workdir, WorkdirSession, WorkdirSessionHandle}; use crate::{Workdir, WorkdirSession};
/// Provides a fresh bearer token for each Runtime request. Backend /// Provides a fresh bearer token for each Runtime request. Backend
/// implementations can mint short-lived capability tokens without making a /// implementations can mint short-lived capability tokens without making a
/// Worker-bound session expire with the token used to open it. /// Worker-bound session expire with the token used to open it.
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync { 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>); struct FixedBearerToken(Arc<str>);
@@ -307,7 +315,12 @@ mod client {
} }
impl WorkdirHttpAuthorization for FixedBearerToken { 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()) Ok(self.0.to_string())
} }
} }
@@ -324,7 +337,6 @@ mod client {
workdir: Workdir, workdir: Workdir,
session_id: WorkdirSessionId, session_id: WorkdirSessionId,
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
delegations: Vec<crate::WorkdirDelegationRequest>,
closed: AtomicBool, closed: AtomicBool,
} }
@@ -357,10 +369,14 @@ mod client {
&base_url, &base_url,
&["v1", "working-directories", workdir_id.as_str(), "sessions"], &["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 let response = client
.post(url) .post(url)
.bearer_auth(authorization.bearer_token()?) .bearer_auth(token)
.json(&request) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -377,7 +393,6 @@ mod client {
workdir: Workdir::new(opened.workdir_id.as_str()), workdir: Workdir::new(opened.workdir_id.as_str()),
session_id: opened.session_id, session_id: opened.session_id,
capabilities: opened.capabilities, capabilities: opened.capabilities,
delegations: Vec::new(),
closed: AtomicBool::new(false), closed: AtomicBool::new(false),
}) })
} }
@@ -404,15 +419,16 @@ mod client {
"operations", "operations",
], ],
)?; )?;
let operation = WorkdirSessionOperationRequest { let operation = WorkdirSessionOperationRequest { operation };
delegations: self.delegations.clone(), let body = serde_json::to_vec(&operation)
operation, .map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
}; let token = self.authorization.bearer_token("POST", url.path(), &body)?;
let response = self let response = self
.client .client
.post(url) .post(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.json(&operation) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -436,35 +452,30 @@ mod client {
self.capabilities self.capabilities
} }
fn transports_delegation_context(&self) -> bool { async fn authorize_scope_path(
true &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, &self,
request: &crate::WorkdirDelegationRequest, request: WorkdirScopeOverlapRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> { ) -> Result<bool, WorkdirError> {
if self.closed.load(Ordering::Acquire) { match self
return Err(WorkdirError::SessionClosed); .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> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
@@ -581,10 +592,11 @@ mod client {
&self.base_url, &self.base_url,
&["v1", "workdir-sessions", self.session_id.as_str()], &["v1", "workdir-sessions", self.session_id.as_str()],
)?; )?;
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
let response = self let response = self
.client .client
.delete(url) .delete(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
+24 -29
View File
@@ -5,10 +5,10 @@
//! bound to one Worker. Tools consume sessions; they do not own Workdir //! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup. //! materialization or cleanup.
mod delegation;
pub mod http; pub mod http;
mod local; mod local;
mod operation; mod operation;
mod scope;
pub mod workspace; pub mod workspace;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -18,11 +18,6 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; use tokio::sync::broadcast;
pub use delegation::{
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
apply_delegation_chain, delegation_capable_session,
};
pub use fs_operation::{ pub use fs_operation::{
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
@@ -32,6 +27,11 @@ pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink, LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
}; };
pub use operation::*; pub use operation::*;
pub use scope::{
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
WorkdirToolScopeRule,
};
/// Persistent, opaque identity of one materialized Workdir. /// Persistent, opaque identity of one materialized Workdir.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[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 workdir(&self) -> &Workdir;
fn capabilities(&self) -> WorkdirSessionCapabilities; fn capabilities(&self) -> WorkdirSessionCapabilities;
fn is_delegation_capable(&self) -> bool { /// Validate an attenuated filesystem rule at the provider boundary without
false /// 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(
/// 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(
&self, &self,
_request: &WorkdirDelegationRequest, request: WorkdirScopeAuthorizationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> { ) -> 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( Err(WorkdirError::Denied(
"workdir provider does not support delegated sessions".into(), "Workdir provider cannot establish resolved scope authority".to_string(),
)) ))
} }
}
/// Attenuate this session into a revocable child lease. Only sessions async fn scope_rules_overlap(
/// created with [`delegation_capable_session`] implement this operation.
async fn delegate(
&self, &self,
_request: WorkdirDelegationRequest, _request: WorkdirScopeOverlapRequest,
) -> Result<WorkdirDelegation, WorkdirError> { ) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied( Err(WorkdirError::Denied(
"workdir session is not delegation-capable".into(), "Workdir provider cannot compare resolved scope authority".to_string(),
)) ))
} }
+353 -82
View File
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait; use async_trait::async_trait;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope}; use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::{Mutex, broadcast, watch}; use tokio::sync::{Mutex, broadcast, watch};
@@ -28,9 +28,9 @@ use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
WriteResult, WriteResult,
}; };
#[cfg(test)] #[cfg(test)]
@@ -213,6 +213,52 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
fn is_writable(&self, path: &Path) -> bool { fn is_writable(&self, path: &Path) -> bool {
self.0.is_writable(path) self.0.is_writable(path)
} }
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
matches!(
self.0.permission_at_paths(logical, resolved),
Some(Permission::Read | Permission::Write)
)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
}
}
fn path_sets_overlap(
left: &Path,
left_recursive: bool,
right: &Path,
right_recursive: bool,
) -> bool {
match (left_recursive, right_recursive) {
(true, true) => left.starts_with(right) || right.starts_with(left),
(true, false) => {
right.starts_with(left)
|| left == right
|| left.parent().is_some_and(|parent| parent == right)
}
(false, true) => {
left.starts_with(right)
|| left == right
|| right.parent().is_some_and(|parent| parent == left)
}
(false, false) => {
left == right
|| left.parent().is_some_and(|parent| parent == right)
|| right.parent().is_some_and(|parent| parent == left)
}
}
}
fn rule_targets(
root: &Path,
rule: &crate::WorkdirToolScopeRule,
) -> std::io::Result<(PathBuf, PathBuf)> {
let logical = root.join(rule.target.as_str());
let resolved = fs_operation::resolve_access_path(&logical)?;
Ok((logical, resolved))
} }
#[derive(Debug)] #[derive(Debug)]
@@ -399,6 +445,11 @@ impl LocalWorkdirSession {
return Err(WorkdirError::RelativePath(path.to_path_buf())); return Err(WorkdirError::RelativePath(path.to_path_buf()));
} }
let symlink = first_symlink(path); let symlink = first_symlink(path);
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(broken_symlink_error(path, info));
}
let scope = self.inner.scope.load(); let scope = self.inner.scope.load();
if !scope.is_readable(path) { if !scope.is_readable(path) {
return Err(symlink_out_of_scope_or_plain( return Err(symlink_out_of_scope_or_plain(
@@ -408,11 +459,6 @@ impl LocalWorkdirSession {
&scope, &scope,
)); ));
} }
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(broken_symlink_error(path, info));
}
}
let meta = std::fs::metadata(path).map_err(|e| match e.kind() { let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()), std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
_ => WorkdirError::io(path, e), _ => WorkdirError::io(path, e),
@@ -558,68 +604,85 @@ impl WorkdirSession for LocalWorkdirSession {
self.inner.capabilities self.inner.capabilities
} }
async fn capture_delegation_source( async fn authorize_scope_path(
&self, &self,
request: &WorkdirDelegationRequest, request: WorkdirScopeAuthorizationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> { ) -> Result<(), WorkdirError> {
let host_rules = request self.ensure_open()?;
.rules let logical = self.inner.root.join(request.path.as_str());
.iter() let resolved = fs_operation::resolve_access_path(&logical)
.map(|rule| ScopeRule { .map_err(|error| WorkdirError::io(&logical, error))?;
target: self.inner.root.join(rule.target.as_str()), let parent_permission = self
permission: match rule.permission { .inner
WorkdirDelegationPermission::Read => Permission::Read, .scope
WorkdirDelegationPermission::Write => Permission::Write, .load()
}, .permission_at_paths(&logical, &resolved);
recursive: rule.recursive, let parent_allows = match request.permission {
}) WorkdirToolScopePermission::Read => matches!(
.collect::<Vec<_>>(); parent_permission,
for (logical, host) in request.rules.iter().zip(&host_rules) { Some(Permission::Read | Permission::Write)
if logical.permission == WorkdirDelegationPermission::Write { ),
let resolved = Scope::resolved_target(host) WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
.map_err(|error| WorkdirError::Denied(error.to_string()))?; };
if resolved != host.target { if !parent_allows {
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::Denied(format!(
"write delegation target `{}` traverses a symlink", "Workdir path `{}` exceeds the provider attachment scope",
logical.target request.path
))); )));
} }
} let allowed = request.rules.iter().any(|rule| {
} if request.permission == WorkdirToolScopePermission::Write
let parent_scope = self.inner.scope.snapshot(); && rule.permission != WorkdirToolScopePermission::Write
for rule in &host_rules {
if !parent_scope
.allows_rule(rule)
.map_err(|error| WorkdirError::Denied(error.to_string()))?
{ {
return Err(WorkdirError::Denied(format!( return false;
"delegated provider scope `{}` exceeds the parent session",
rule.target.display()
)));
} }
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)
} }
let child_scope = Scope::from_config(&ScopeConfig { };
allow: host_rules, if rule.recursive {
deny: Vec::new(), candidate.starts_with(target)
}) } else {
.map_err(|error| WorkdirError::Denied(error.to_string()))?; candidate == target || candidate.parent() == Some(target.as_path())
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())
{
return Err(WorkdirError::Denied(format!(
"delegated cwd `{}` is not a readable Workdir directory",
request.cwd
)));
} }
Ok(Arc::new(LocalWorkdirSession::materialized_bound( });
self.inner.workdir.clone(), if allowed {
self.inner.root.clone(), Ok(())
self.inner.root.clone(), } else {
SharedScope::new(child_scope), Err(WorkdirError::Denied(format!(
self.inner.capabilities, "Workdir path `{}` is outside the provider-resolved delegated scope",
request.path
))) )))
} }
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.ensure_open()?;
let (left_logical, left_resolved) = rule_targets(&self.inner.root, &request.left)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
let (right_logical, right_resolved) = rule_targets(&self.inner.root, &request.right)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
Ok(path_sets_overlap(
&left_logical,
request.left.recursive,
&right_logical,
request.right.recursive,
) || path_sets_overlap(
&left_resolved,
request.left.recursive,
&right_resolved,
request.right.recursive,
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?; self.ensure_capability(WorkdirSessionCapability::Read)?;
@@ -694,9 +757,20 @@ impl WorkdirSession for LocalWorkdirSession {
{ {
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf())); 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 id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
let handle = CommandHandle(format!("command-{id}")); let handle = CommandHandle(format!("command-{id}"));
let cwd = self.inner.cwd.clone();
let (completion_tx, completion) = watch::channel(false); let (completion_tx, completion) = watch::channel(false);
let command_id = handle.0.clone(); let command_id = handle.0.clone();
let telemetry = self.inner.command_telemetry.clone(); let telemetry = self.inner.command_telemetry.clone();
@@ -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] #[tokio::test]
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() { async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1516,6 +1606,7 @@ mod tests {
command: "sleep 30".to_owned(), command: "sleep 30".to_owned(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -1586,6 +1677,102 @@ mod tests {
assert_eq!(read.bytes, b"persisted"); assert_eq!(read.bytes, b"persisted");
} }
#[cfg(unix)]
#[tokio::test]
async fn resolved_provider_scope_rejects_read_and_write_through_outside_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let target = outside.path().join("target.txt");
fs::write(&target, "secret").unwrap();
symlink(&target, root.path().join("alias.txt")).unwrap();
symlink(outside.path(), root.path().join("alias-dir")).unwrap();
let workdir = make_fs(&root);
assert!(matches!(
WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
content: b"changed".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert_eq!(fs::read_to_string(target).unwrap(), "secret");
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias-dir/new.txt").unwrap(),
content: b"new".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("new.txt").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn resolved_deny_blocks_missing_write_through_logical_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
symlink(outside.path(), root.path().join("alias")).unwrap();
let workdir = LocalWorkdirSession::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: vec![ScopeRule {
target: outside.path().join("blocked.txt"),
permission: Permission::Read,
recursive: false,
symlink_policy: SymlinkPolicy::Logical,
}],
})
.unwrap(),
root.path().to_path_buf(),
);
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias/blocked.txt").unwrap(),
content: b"blocked".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("blocked.txt").exists());
}
#[tokio::test] #[tokio::test]
async fn capability_boundary_rejects_direct_unsupported_operation() { async fn capability_boundary_rejects_direct_unsupported_operation() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1688,7 +1875,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[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; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1698,16 +1885,8 @@ mod tests {
let link = dir.path().join("outside-repo.txt"); let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_logical_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err(); assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
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:?}"
);
} }
#[cfg(unix)] #[cfg(unix)]
@@ -1799,7 +1978,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn write_reports_symlink_target_outside_scope() { fn write_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1809,15 +1988,14 @@ mod tests {
let link = dir.path().join("outside-repo.txt"); let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_logical_fs(&dir);
let err = fs.write(&link, b"new").unwrap_err(); fs.write(&link, b"new").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!( assert!(
matches!( fs::symlink_metadata(&link)
err, .unwrap()
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" } .file_type()
if path == &link && err_target == &target.canonicalize().unwrap() .is_symlink()
),
"expected write symlink out-of-scope diagnostic, got {err:?}"
); );
} }
@@ -1840,11 +2018,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}; };
let scope = Scope::from_config(&cfg).unwrap(); let scope = Scope::from_config(&cfg).unwrap();
@@ -1908,6 +2088,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1944,6 +2125,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1980,6 +2162,7 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -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] #[tokio::test]
async fn provider_executes_glob_grep_and_command_at_the_materialization() { async fn provider_executes_glob_grep_and_command_at_the_materialization() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -2043,6 +2303,7 @@ mod tests {
command: "pwd && printf provider-command".into(), command: "pwd && printf provider-command".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 4096, output_limit: 4096,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -2081,11 +2342,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -2141,6 +2404,7 @@ mod tests {
command: "printf hidden".into(), command: "printf hidden".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1, output_limit: 1,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()), spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None, tool_call_id: None,
}, },
@@ -2161,11 +2425,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -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(), 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, timeout_secs: 5,
output_limit: 64, output_limit: 64,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()), spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None, tool_call_id: None,
}, },
@@ -2224,6 +2491,7 @@ mod tests {
command: "printf 'aéz'".into(), command: "printf 'aéz'".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -2449,6 +2717,7 @@ mod tests {
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: Some("tool-7".into()), tool_call_id: Some("tool-7".into()),
}, },
@@ -2553,6 +2822,7 @@ mod tests {
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 1, timeout_secs: 1,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -2623,6 +2893,7 @@ mod tests {
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
+4
View File
@@ -11,6 +11,10 @@ pub struct CommandRequest {
pub command: String, pub command: String,
pub timeout_secs: u64, pub timeout_secs: u64,
pub output_limit: usize, 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 /// Provider-local directory where complete output is retained when the
/// inline result exceeds `output_limit`. /// inline result exceeds `output_limit`.
pub spill_dir: Option<PathBuf>, pub spill_dir: Option<PathBuf>,
File diff suppressed because it is too large Load Diff
-10
View File
@@ -104,15 +104,5 @@ mod tests {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionOperationRequest { 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, pub operation: crate::http::WorkdirSessionOperation,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionFence {
pub value: String,
}
+3 -1
View File
@@ -39,7 +39,9 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
ring.workspace = true ring.workspace = true
tar.workspace = true tar.workspace = true
thiserror = { 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 toml.workspace = true
url.workspace = true url.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
+87 -220
View File
@@ -2,6 +2,7 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fmt; use std::fmt;
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:"; const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8: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"; 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_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1."; const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
WrongMutationTarget, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeIdentityMaterial { pub struct RuntimeIdentityMaterial {
pub identity_id: String, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthContext { pub struct RuntimeAuthContext {
pub server_id: String, pub server_id: String,
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
pub expires_at: u64, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims { pub struct RuntimeRequestSourceClaims {
pub iss: String, pub iss: String,
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
exp: now_unix.saturating_add(ttl_seconds), exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?, 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 private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private) let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref()); sign_json_token(
Ok(format!( RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}" RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
)) &key_pair,
&claims,
)
} }
} }
pub fn decode_runtime_request_source_claims( pub fn decode_runtime_request_source_claims(
proof: &str, proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?; Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
} }
pub fn verify_runtime_request_source( pub fn verify_runtime_request_source(
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
public_key: &str, public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>, expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?; let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { proof,
return Err(RuntimeAuthError::InvalidTokenFormat); RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
} )?;
let signature = URL_SAFE_NO_PAD.decode(signature)?; verify_signed_json_token(
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
let public_key = decode_public_key(public_key)?; &signed.payload,
UnparsedPublicKey::new(&ED25519, public_key) &signed.signature,
.verify(signing_input.as_bytes(), &signature) public_key,
.map_err(|_| RuntimeAuthError::InvalidSignature)?; )?;
let claims = decode_runtime_request_source_claims(proof)?; let claims = signed.claims;
if claims.iss != expected.identity_id if claims.iss != expected.identity_id
|| claims.aud != expected.audience || claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id || claims.workspace_id != expected.workspace_id
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
Ok(claims) Ok(claims)
} }
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims { pub struct WorkerMutationSourceClaims {
pub iss: String, pub iss: String,
@@ -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 { pub fn encode_public_key(bytes: &[u8]) -> String {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)) format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
} }
@@ -851,46 +760,4 @@ mod tests {
Err(RuntimeAuthError::Expired) 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 { .. })
));
}
} }
+18 -19
View File
@@ -15,32 +15,22 @@ pub enum ProfileSelector {
Named(String), 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. /// Profile source material available to a Runtime during Worker creation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSourceArchiveSource { pub enum ProfileSourceArchiveSource {
/// Backend-internal embedded runtimes may receive already-built archive bytes. /// Backend-internal embedded runtimes may receive already-built archive bytes.
Embedded { archive: ProfileSourceArchive }, Embedded { archive: ProfileSourceArchive },
/// Standalone runtimes fetch/cache the tar archive over HTTP. /// Standalone runtimes resolve this immutable archive from the latest
Http { /// Workspace Config bundle before creating the Worker.
location: ProfileSourceArchiveHttpRef, WorkspaceConfig { archive: ProfileSourceArchiveRef },
},
} }
impl ProfileSourceArchiveSource { impl ProfileSourceArchiveSource {
pub fn reference(&self) -> ProfileSourceArchiveRef { pub fn reference(&self) -> ProfileSourceArchiveRef {
match self { match self {
Self::Embedded { archive } => archive.reference.clone(), 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess { pub struct RepositorySshCredentialCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, 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_id: String,
pub host_trust_revision: u64, pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode, pub access: workspace_api::RepositoryAccessMode,
@@ -141,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
pub repository_uri: String, pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle, pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)] #[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString, pub known_hosts_entry: SensitiveString,
} }
@@ -153,8 +148,6 @@ pub struct RepositoryMaterializationContext {
pub operation_id: String, pub operation_id: String,
pub config_revision: u64, pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>, pub ssh: Option<RepositorySshMaterializationAccess>,
} }
@@ -307,6 +300,8 @@ pub struct WorkerSummary {
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[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>, pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>, pub working_directory: Option<WorkingDirectoryStatus>,
@@ -325,6 +320,8 @@ pub struct WorkerDetail {
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[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>, pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>, pub working_directory: Option<WorkingDirectoryStatus>,
@@ -341,6 +338,8 @@ pub struct WorkerDetail {
pub struct WorkerLifecycleAck { pub struct WorkerLifecycleAck {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
} }
#[cfg(test)] #[cfg(test)]
@@ -9,6 +9,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "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. /// Backend-synced Profile/config bundle stored by a Runtime.
/// ///
+70 -45
View File
@@ -1,6 +1,7 @@
use crate::catalog::{ use crate::catalog::{
RepositoryRefObservation, RepositoryRefObservationRequest, ConfigBundleRef, ProfileSelector, RepositoryRefObservation, RepositoryRefObservationRequest,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
}; };
use crate::config_bundle::ConfigBundle; use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError; use crate::error::RuntimeError;
@@ -15,18 +16,6 @@ use std::fmt;
use std::sync::Arc; use std::sync::Arc;
use workdir::WorkdirSessionHandle; 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. /// Execution operation that produced a result.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -41,14 +30,12 @@ pub enum WorkerExecutionOperation {
Cancel, Cancel,
} }
/// Evidence that a user input reached the durable Worker session boundary. /// Evidence that a Submit request 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.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerInputCommitAck { pub struct WorkerSubmissionAck {
pub submission_request_id: String,
pub submission_id: String, pub submission_id: String,
pub disposition: protocol::SubmissionDisposition,
} }
/// Typed execution result class. Results are transient operation outcomes and /// Typed execution result class. Results are transient operation outcomes and
@@ -57,11 +44,12 @@ pub struct WorkerInputCommitAck {
pub struct WorkerExecutionResult { pub struct WorkerExecutionResult {
pub operation: WorkerExecutionOperation, pub operation: WorkerExecutionOperation,
pub outcome: WorkerExecutionOutcome, 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>, pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[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. /// Backend result class for a Worker execution operation.
@@ -76,31 +64,36 @@ pub enum WorkerExecutionOutcome {
} }
impl WorkerExecutionResult { impl WorkerExecutionResult {
pub fn accepted( pub fn accepted(operation: WorkerExecutionOperation) -> Self {
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
) -> Self {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Accepted, outcome: WorkerExecutionOutcome::Accepted,
run_state, worker_state: None,
message: 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, operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState, submission_request_id: impl Into<String>,
submission_id: impl Into<String>, submission_id: impl Into<String>,
disposition: protocol::SubmissionDisposition,
) -> Self { ) -> Self {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Accepted, outcome: WorkerExecutionOutcome::Accepted,
run_state, worker_state: None,
message: None, message: None,
input_commit: Some(WorkerInputCommitAck { submission: Some(WorkerSubmissionAck {
submission_request_id: submission_request_id.into(),
submission_id: submission_id.into(), submission_id: submission_id.into(),
disposition,
}), }),
} }
} }
@@ -109,9 +102,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Busy, outcome: WorkerExecutionOutcome::Busy,
run_state: WorkerExecutionRunState::Busy, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -119,9 +112,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Rejected, outcome: WorkerExecutionOutcome::Rejected,
run_state: WorkerExecutionRunState::Stopped, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -129,9 +122,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Errored, outcome: WorkerExecutionOutcome::Errored,
run_state: WorkerExecutionRunState::Errored, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -139,9 +132,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Unsupported, outcome: WorkerExecutionOutcome::Unsupported,
run_state: WorkerExecutionRunState::Stopped, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -273,12 +266,28 @@ pub struct WorkerExecutionRestoreRequest {
pub config_bundle: Option<ConfigBundle>, 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. /// Backend outcome for Worker spawn/restore operations.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum WorkerExecutionSpawnResult { pub enum WorkerExecutionSpawnResult {
Connected { Connected {
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState, worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>, working_directory: Option<WorkingDirectoryStatus>,
}, },
Rejected(WorkerExecutionResult), Rejected(WorkerExecutionResult),
@@ -288,12 +297,12 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult { impl WorkerExecutionSpawnResult {
pub fn connected( pub fn connected(
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState, worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>, working_directory: Option<WorkingDirectoryStatus>,
) -> Self { ) -> Self {
Self::Connected { Self::Connected {
handle, handle,
run_state, worker_state,
working_directory, working_directory,
} }
} }
@@ -302,6 +311,13 @@ impl WorkerExecutionSpawnResult {
pub trait WorkerExecutionBackend: Send + Sync + 'static { pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str; 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 spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
fn restore_worker( 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( pub(crate) fn spawn_worker(
&self, &self,
request: WorkerExecutionSpawnRequest, request: WorkerExecutionSpawnRequest,
@@ -618,14 +641,16 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn input_commit_ack_survives_json_round_trip() { fn submission_ack_survives_json_round_trip() {
let result = WorkerExecutionResult::accepted_input_committed( let result = WorkerExecutionResult::accepted_submission(
WorkerExecutionOperation::Input, WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy, "request-1",
"submission-1", "submission-1",
protocol::SubmissionDisposition::Started,
); );
let json = serde_json::to_string(&result).unwrap(); let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"submission_request_id\":\"request-1\""));
assert!(json.contains("\"submission_id\":\"submission-1\"")); assert!(json.contains("\"submission_id\":\"submission-1\""));
assert_eq!( assert_eq!(
serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(), serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(),
+289 -28
View File
@@ -15,7 +15,10 @@ use std::io::{BufReader, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; 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 RUNTIME_FILE: &str = "runtime.json";
const WORKERS_DIR: &str = "workers"; const WORKERS_DIR: &str = "workers";
const WORKER_FILE: &str = "worker.json"; const WORKER_FILE: &str = "worker.json";
@@ -283,6 +286,7 @@ pub(crate) struct PersistedWorkerExecutionBinding {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistedWorkerExecution { pub(crate) struct PersistedWorkerExecution {
pub(crate) last_run_generation: u64,
pub(crate) binding: Option<PersistedWorkerExecutionBinding>, pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
pub(crate) restore_intent: WorkerRestoreIntent, 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"), format!("Runtime store schema version {schema_version} is out of range"),
) )
})?; })?;
let staging = migration_sibling(root, "schema-v4-staging")?; let staging = migration_sibling(root, "schema-v6-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?; let backup = migration_sibling(root, "pre-schema-v6-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -397,11 +401,14 @@ fn plan_runtime_store_migration(
}; };
return Ok((plan, Vec::new())); 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( return Err(runtime_store_corrupt(
&runtime_path, &runtime_path,
format!( 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()) runtime_store_corrupt(&source_dir, "Worker directory is not UTF-8".to_string())
})?; })?;
let snapshot_path = source_dir.join(WORKER_FILE); 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 snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 { let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 {
let legacy_worker_id = name.parse::<u64>().map_err(|_| { let legacy_worker_id = name.parse::<u64>().map_err(|_| {
@@ -616,6 +633,38 @@ fn migrate_v1_worker_document(
Ok(snapshot) 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( fn migrate_worker_document(
mut document: serde_json::Value, mut document: serde_json::Value,
source_schema_version: u32, source_schema_version: u32,
@@ -640,7 +689,7 @@ fn migrate_worker_document(
"Worker snapshot must be an object".to_string(), "Worker snapshot must be an object".to_string(),
) )
})?; })?;
let run_generation = object let declared_run_generation = object
.remove("run_generation") .remove("run_generation")
.map(|value| { .map(|value| {
value.as_u64().ok_or_else(|| { value.as_u64().ok_or_else(|| {
@@ -650,9 +699,45 @@ fn migrate_worker_document(
) )
}) })
}) })
.transpose()? .transpose()?;
.filter(|generation| *generation > 0);
let legacy_execution = object.remove("execution"); 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 !object.contains_key("working_directory") {
if let Some(working_directory) = legacy_execution if let Some(working_directory) = legacy_execution
.as_ref() .as_ref()
@@ -663,6 +748,42 @@ fn migrate_worker_document(
object.insert("working_directory".to_string(), working_directory); 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( object.insert(
"schema_version".to_string(), "schema_version".to_string(),
serde_json::Value::from(SCHEMA_VERSION), serde_json::Value::from(SCHEMA_VERSION),
@@ -674,9 +795,8 @@ fn migrate_worker_document(
object.insert( object.insert(
"execution".to_string(), "execution".to_string(),
serde_json::json!({ serde_json::json!({
"binding": run_generation.map(|run_generation| { "last_run_generation": run_generation,
serde_json::json!({ "run_generation": run_generation }) "binding": null,
}),
"restore_intent": "explicit", "restore_intent": "explicit",
}), }),
); );
@@ -759,8 +879,8 @@ fn migrate_worker_aggregate_document(
.get_mut("resolved_manifest_snapshot") .get_mut("resolved_manifest_snapshot")
.filter(|snapshot| !snapshot.is_null()) .filter(|snapshot| !snapshot.is_null())
{ {
let manifest: manifest::WorkerManifest = let mut manifest = manifest::read_persisted_worker_manifest_snapshot(snapshot.clone())
serde_json::from_value(snapshot.clone()).map_err(|error| { .map_err(|error| {
runtime_store_corrupt( runtime_store_corrupt(
metadata_path, metadata_path,
format!("decode Worker aggregate resolved manifest snapshot: {error}"), format!("decode Worker aggregate resolved manifest snapshot: {error}"),
@@ -775,20 +895,14 @@ fn migrate_worker_aggregate_document(
), ),
)); ));
} }
snapshot manifest.worker.name = expected_name.clone();
.as_object_mut() *snapshot =
.and_then(|manifest| manifest.get_mut("worker")) manifest::write_persisted_worker_manifest_snapshot(&manifest).map_err(|error| {
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
runtime_store_corrupt( runtime_store_corrupt(
metadata_path, 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( metadata.insert(
"worker_name".to_string(), "worker_name".to_string(),
@@ -809,8 +923,8 @@ fn migrate_worker_aggregate_document(
)); ));
} }
if let Some(snapshot) = metadata.resolved_manifest_snapshot { if let Some(snapshot) = metadata.resolved_manifest_snapshot {
let manifest: manifest::WorkerManifest = let manifest =
serde_json::from_value(snapshot).map_err(|error| { manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|error| {
runtime_store_corrupt( runtime_store_corrupt(
metadata_path, metadata_path,
format!("decode migrated Worker aggregate resolved manifest: {error}"), format!("decode migrated Worker aggregate resolved manifest: {error}"),
@@ -1054,8 +1168,8 @@ fn migrate_runtime_store(
if !plan.migration_required { if !plan.migration_required {
return Ok(plan); return Ok(plan);
} }
let staging = migration_sibling(root, "schema-v4-staging")?; let staging = migration_sibling(root, "schema-v6-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?; let backup = migration_sibling(root, "pre-schema-v6-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, 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) { match (self.status, self.execution.restore_intent) {
(status, WorkerRestoreIntent::Automatic) if status.is_active() => { (status, WorkerRestoreIntent::Automatic) if status.is_active() => {
let Some(binding) = self.execution.binding.as_ref() else { let Some(binding) = self.execution.binding.as_ref() else {
@@ -1497,3 +1623,138 @@ fn sync_directory(path: &Path, operation: &'static str) -> Result<(), RuntimeErr
source, 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
+24 -6
View File
@@ -25,10 +25,10 @@ impl WorkerInputKind {
pub struct WorkerInput { pub struct WorkerInput {
pub kind: WorkerInputKind, pub kind: WorkerInputKind,
pub content: String, pub content: String,
/// Runtime-generated correlation id. This is never accepted from public /// Authenticated client-generated idempotency key. Runtime generates one
/// JSON input and is consumed only by the execution backend. /// only for trusted internal callers that omit it.
#[serde(skip)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_id: Option<String>, pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<Segment>>, pub segments: Option<Vec<Segment>>,
} }
@@ -38,7 +38,7 @@ impl WorkerInput {
Self { Self {
kind: WorkerInputKind::User, kind: WorkerInputKind::User,
content: content.into(), content: content.into(),
submission_id: None, submission_request_id: None,
segments: None, segments: None,
} }
} }
@@ -47,7 +47,7 @@ impl WorkerInput {
Self { Self {
kind: WorkerInputKind::Notify, kind: WorkerInputKind::Notify,
content: content.into(), content: content.into(),
submission_id: None, submission_request_id: None,
segments: None, segments: None,
} }
} }
@@ -57,6 +57,21 @@ impl WorkerInput {
mod tests { mod tests {
use super::WorkerInput; 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] #[test]
fn notify_is_an_operation_and_legacy_system_kind_is_rejected() { fn notify_is_an_operation_and_legacy_system_kind_is_rejected() {
assert_eq!( assert_eq!(
@@ -78,4 +93,7 @@ mod tests {
pub struct WorkerInteractionAck { pub struct WorkerInteractionAck {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub status: WorkerStatus, 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>,
} }
+4
View File
@@ -25,9 +25,13 @@ pub mod resource;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub mod retention; pub mod retention;
mod runtime; mod runtime;
pub mod ssh_host_key_probe;
pub mod worker_backend; pub mod worker_backend;
pub mod worker_source; pub mod worker_source;
pub mod working_directory; pub mod working_directory;
pub mod workspace_issuer;
#[cfg(feature = "http-server")]
pub mod workspace_request;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
File diff suppressed because it is too large Load Diff
+155 -77
View File
@@ -1,13 +1,9 @@
use crate::auth::{ use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION;
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::identity::WorkerId; use crate::identity::WorkerId;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex}; use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str = pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar"; "application/vnd.yoi.profile-source-archive+tar";
@@ -15,16 +11,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json"; "application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024; 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)] #[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret { pub struct RepositorySshAccessSecret {
pub private_key: String, pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
pub known_hosts_entry: String, pub known_hosts_entry: String,
} }
impl Drop for RepositorySshAccessSecret { impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) { fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry); 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 { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter formatter
.debug_struct("RepositorySshAccessSecret") .debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]") .field("credential_candidates", &self.credential_candidates)
.field("known_hosts_entry", &"[REDACTED]") .field("known_hosts_entry", &"[REDACTED]")
.finish() .finish()
} }
@@ -144,6 +165,8 @@ pub enum BackendResourceError {
Oversized { max_bytes: u64, actual_bytes: u64 }, Oversized { max_bytes: u64, actual_bytes: u64 },
#[error("backend resource content type mismatch: expected {expected}, got {actual}")] #[error("backend resource content type mismatch: expected {expected}, got {actual}")]
ContentTypeMismatch { expected: String, actual: String }, ContentTypeMismatch { expected: String, actual: String },
#[error("backend resource fetch timed out")]
Timeout,
#[error("backend resource transport failed: {message}")] #[error("backend resource transport failed: {message}")]
Transport { message: String }, Transport { message: String },
#[error("backend resource response is invalid: {message}")] #[error("backend resource response is invalid: {message}")]
@@ -163,9 +186,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
pub struct HttpBackendResourceClient { pub struct HttpBackendResourceClient {
endpoint: String, endpoint: String,
bearer_token: Option<String>, bearer_token: Option<String>,
request_source_signer: Option<RuntimeRequestSourceSigner>, workspace_request_client: Option<RuntimeWorkspaceRequestClient>,
request_source_audience: Option<String>, request_timeout: std::time::Duration,
client: reqwest::Client,
} }
#[cfg(feature = "http-server")] #[cfg(feature = "http-server")]
@@ -174,19 +196,18 @@ impl HttpBackendResourceClient {
Self { Self {
endpoint: endpoint.into(), endpoint: endpoint.into(),
bearer_token, bearer_token,
request_source_signer: None, workspace_request_client: None,
request_source_audience: None, request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
client: reqwest::Client::new(),
} }
} }
pub fn with_runtime_request_source( pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
mut self, self.request_timeout = timeout;
identity: &RuntimeIdentityMaterial, self
audience: impl Into<String>, }
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity)); pub fn with_workspace_request_client(mut self, client: RuntimeWorkspaceRequestClient) -> Self {
self.request_source_audience = Some(audience.into()); self.workspace_request_client = Some(client);
self self
} }
} }
@@ -208,55 +229,73 @@ impl BackendResourceClient for HttpBackendResourceClient {
message: error.to_string(), message: error.to_string(),
} }
})?; })?;
let mut builder = self let client = self.workspace_request_client.as_ref().ok_or_else(|| {
.client
.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 { BackendResourceError::Unauthorized {
message: "Runtime request proof audience is unavailable".to_owned(), message: "Workspace request client is unavailable".to_string(),
} }
})?; })?;
let proof = signer if client.workspace_id() != request.handle.workspace_id {
.issue( return Err(BackendResourceError::Unauthorized {
audience, message: "Workspace request client does not match the resource workspace"
&request.handle.workspace_id, .to_string(),
None, });
BACKEND_RESOURCE_FETCH_PERMISSION, }
"POST", let base_url = client.base_url().trim_end_matches('/');
endpoint.path(), let endpoint_text = endpoint.as_str();
&body, let endpoint_suffix = endpoint_text.strip_prefix(base_url).ok_or_else(|| {
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), BackendResourceError::Unauthorized {
30, message: "Workspace resource endpoint does not match its request client"
) .to_string(),
.map_err(|error| BackendResourceError::Unauthorized { }
})?;
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(), 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() { let response = client
builder.bearer_auth(token) .execute(RuntimeWorkspaceRequest {
method: reqwest::Method::POST,
path_and_query: endpoint_suffix.to_string(),
body,
headers,
permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(),
worker_id: None,
timeout: Some(self.request_timeout),
max_response_bytes: 8 * 1024 * 1024,
})
.await
.map_err(|error| {
if error.is_timeout() {
BackendResourceError::Timeout
} else { } else {
builder BackendResourceError::Transport {
}; message: error.to_string(),
let response = builder }
.send() }
.await
.map_err(|err| BackendResourceError::Transport {
message: err.to_string(),
})?; })?;
if response.status().is_success() { if response.status.is_success() {
response serde_json::from_slice::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
.json::<BackendResourceFetchResponse>() BackendResourceError::InvalidResponse {
.await
.map_err(|err| BackendResourceError::InvalidResponse {
message: err.to_string(), message: err.to_string(),
}
}) })
} else { } else {
let status = response.status(); let status = response.status;
match response.json::<BackendResourceError>().await { match serde_json::from_slice::<BackendResourceError>(&response.body) {
Ok(error) => Err(error), Ok(error) => Err(error),
Err(err) => Err(BackendResourceError::Transport { Err(err) => Err(BackendResourceError::Transport {
message: format!("backend resource fetch failed with HTTP {status}: {err}"), message: format!("backend resource fetch failed with HTTP {status}: {err}"),
@@ -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( pub fn build_profile_source_archive_fetch_request(
handle: BackendResourceHandle, handle: BackendResourceHandle,
runtime_id: &str, runtime_id: &str,
@@ -364,6 +386,7 @@ pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), Str
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::RuntimeIdentityMaterial;
use crate::profile_archive::ProfileSourceGraphSummary; use crate::profile_archive::ProfileSourceGraphSummary;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -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] #[test]
fn response_verification_detects_digest_mismatch() { fn response_verification_detects_digest_mismatch() {
let bytes = b"archive-bytes"; let bytes = b"archive-bytes";
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
+114 -219
View File
@@ -1,16 +1,16 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use worker::{ use worker::{
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
}; };
use crate::auth::{ use crate::auth::{
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial, RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION, WORKER_REMOVE_PERMISSION, WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION,
WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind, WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
}; };
use crate::runtime::RuntimeWorkspaceScope; use crate::runtime::RuntimeWorkspaceScope;
use crate::worker_backend::WorkspacePromptProjectionCache; use crate::worker_backend::WorkspacePromptProjectionCache;
@@ -133,9 +133,7 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
#[derive(Clone)] #[derive(Clone)]
enum RuntimeWorkerMutationTransport { enum RuntimeWorkerMutationTransport {
Remote { Remote {
base_url: String, request_client: RuntimeWorkspaceRequestClient,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
}, },
Embedded { Embedded {
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>, dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
@@ -155,17 +153,13 @@ impl RuntimeWorkerMutationForwarder {
identity: &RuntimeIdentityMaterial, identity: &RuntimeIdentityMaterial,
scope: RuntimeWorkspaceScope, scope: RuntimeWorkspaceScope,
source_worker_id: impl Into<String>, source_worker_id: impl Into<String>,
base_url: impl Into<String>, request_client: RuntimeWorkspaceRequestClient,
) -> Self { ) -> Self {
Self { Self {
authority: RuntimeWorkerMutationSourceAuthority::remote(identity), authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
scope: scope.clone(), scope: scope.clone(),
source_worker_id: source_worker_id.into(), source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Remote { transport: RuntimeWorkerMutationTransport::Remote { request_client },
base_url: base_url.into().trim_end_matches('/').to_string(),
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
request_source_audience: scope.server_id,
},
} }
} }
@@ -201,18 +195,11 @@ impl RuntimeWorkerMutationForwarder {
)?; )?;
match (&self.transport, proof) { match (&self.transport, proof) {
( (
RuntimeWorkerMutationTransport::Remote { RuntimeWorkerMutationTransport::Remote { request_client },
base_url,
request_source_signer,
request_source_audience,
},
RuntimeOwnedWorkerMutationProof::Remote(token), RuntimeOwnedWorkerMutationProof::Remote(token),
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest { ) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
base_url: base_url.clone(), request_client: request_client.clone(),
workspace_id: self.scope.workspace_id.clone(),
source_worker_id: self.source_worker_id.clone(), source_worker_id: self.source_worker_id.clone(),
request_source_signer: request_source_signer.clone(),
request_source_audience: request_source_audience.clone(),
token, token,
target_runtime_id: target_runtime_id.to_string(), target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(), target_worker_id: target_worker_id.to_string(),
@@ -233,11 +220,8 @@ impl RuntimeWorkerMutationForwarder {
} }
struct RemoteWorkerRemoveHttpRequest { struct RemoteWorkerRemoveHttpRequest {
base_url: String, request_client: RuntimeWorkspaceRequestClient,
workspace_id: String,
source_worker_id: String, source_worker_id: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
token: String, token: String,
target_runtime_id: String, target_runtime_id: String,
target_worker_id: String, target_worker_id: String,
@@ -270,54 +254,54 @@ fn execute_remote_worker_remove_http(
fn execute_remote_worker_remove_http_blocking( fn execute_remote_worker_remove_http_blocking(
request: RemoteWorkerRemoveHttpRequest, request: RemoteWorkerRemoveHttpRequest,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> { ) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let path = format!("/api/w/{}/workers/remove", request.workspace_id); let path = format!(
let url = format!("{}{}", request.base_url, path); "/api/w/{}/workers/remove",
let body = serde_json::to_string(&serde_json::json!({ request.request_client.workspace_id()
);
let body = serde_json::to_vec(&serde_json::json!({
"target_runtime_id": request.target_runtime_id, "target_runtime_id": request.target_runtime_id,
"target_worker_id": request.target_worker_id, "target_worker_id": request.target_worker_id,
"reason": request.reason, "reason": request.reason,
})) }))
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let request_source_proof = request.request_source_signer.issue( let mut headers = reqwest::header::HeaderMap::new();
&request.request_source_audience, headers.insert(
&request.workspace_id,
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, crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
request.token, reqwest::header::HeaderValue::from_str(&request.token)
) .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
.header(reqwest::header::CONTENT_TYPE, "application/json") );
.body(body) headers.insert(
.send() 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()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let status = response.status().as_u16(); let body = String::from_utf8(response.body)
let body = response
.text()
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
Ok(WorkspaceResponse { status, body }) Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
} }
#[derive(Clone)] #[derive(Clone)]
pub struct RuntimeOwnedWorkspaceClient { pub struct RuntimeOwnedWorkspaceClient {
workspace_id: String, workspace_id: String,
base_url: String, request_client: RuntimeWorkspaceRequestClient,
runtime_id: String,
worker_id: String, worker_id: String,
request_timeout: Option<Duration>, request_timeout: Option<Duration>,
worker_remove: Option<RuntimeWorkerMutationForwarder>, worker_remove: Option<RuntimeWorkerMutationForwarder>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>, prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
} }
@@ -328,15 +312,32 @@ impl RuntimeOwnedWorkspaceClient {
runtime_id: impl Into<String>, runtime_id: impl Into<String>,
worker_id: impl Into<String>, worker_id: impl Into<String>,
) -> Self { ) -> Self {
let workspace_id = workspace_id.into();
Self { Self {
workspace_id: workspace_id.into(), request_client: RuntimeWorkspaceRequestClient::new(
base_url: base_url.into().trim_end_matches('/').to_string(), workspace_id.clone(),
runtime_id: runtime_id.into(), base_url,
runtime_id,
),
workspace_id,
worker_id: worker_id.into(),
request_timeout: None,
worker_remove: None,
prompt_projection_cache: None,
}
}
pub(crate) fn from_request_client(
request_client: RuntimeWorkspaceRequestClient,
worker_id: impl Into<String>,
) -> Self {
let workspace_id = request_client.workspace_id().to_string();
Self {
workspace_id,
request_client,
worker_id: worker_id.into(), worker_id: worker_id.into(),
request_timeout: None, request_timeout: None,
worker_remove: None, worker_remove: None,
request_source_signer: None,
request_source_audience: None,
prompt_projection_cache: None, prompt_projection_cache: None,
} }
} }
@@ -351,8 +352,9 @@ impl RuntimeOwnedWorkspaceClient {
identity: &RuntimeIdentityMaterial, identity: &RuntimeIdentityMaterial,
audience: impl Into<String>, audience: impl Into<String>,
) -> Self { ) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity)); self.request_client = self
self.request_source_audience = Some(audience.into()); .request_client
.with_runtime_request_source(identity, audience);
self self
} }
@@ -375,44 +377,43 @@ impl RuntimeOwnedWorkspaceClient {
request: WorkspaceRequest, request: WorkspaceRequest,
permission: &'static str, permission: &'static str,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone(); let method = match request.method {
let workspace_id = self.workspace_id.clone(); WorkspaceRequestMethod::Get => reqwest::Method::GET,
let runtime_id = self.runtime_id.clone(); WorkspaceRequestMethod::Post => reqwest::Method::POST,
let worker_id = self.worker_id.clone(); WorkspaceRequestMethod::Put => reqwest::Method::PUT,
let request_source_signer = self.request_source_signer.clone(); WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
let request_source_audience = self.request_source_audience.clone(); WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
let request_timeout = self.request_timeout; };
if tokio::runtime::Handle::try_current().is_ok() { let body = request.body.unwrap_or_default().into_bytes();
std::thread::spawn(move || { let mut headers = reqwest::header::HeaderMap::new();
execute_runtime_owned_workspace_http( if !body.is_empty() {
&base_url, headers.insert(
&workspace_id, reqwest::header::CONTENT_TYPE,
&runtime_id, reqwest::header::HeaderValue::from_static("application/json"),
&worker_id, );
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout,
permission,
request,
)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.workspace_id,
&self.runtime_id,
&self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout,
permission,
request,
)
} }
let request_label = format!("{method} {}", request.path);
let response = self
.request_client
.execute_blocking(RuntimeWorkspaceRequest {
method,
path_and_query: request.path,
body,
headers,
permission: permission.to_string(),
worker_id: Some(self.worker_id.clone()),
timeout: self.request_timeout,
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| {
WorkspaceClientError::Request(format!("{request_label} failed: {error}"))
})?;
let body = String::from_utf8(response.body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
} }
} }
@@ -420,8 +421,8 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter formatter
.debug_struct("RuntimeOwnedWorkspaceClient") .debug_struct("RuntimeOwnedWorkspaceClient")
.field("workspace_id", &self.workspace_id) .field("workspace_id", &self.request_client.workspace_id())
.field("base_url", &self.base_url) .field("base_url", &self.request_client.base_url())
.field("source", &"Runtime-owned") .field("source", &"Runtime-owned")
.field( .field(
"worker_remove", "worker_remove",
@@ -433,7 +434,7 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
impl WorkspaceClient for RuntimeOwnedWorkspaceClient { impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn workspace_id(&self) -> Option<&str> { fn workspace_id(&self) -> Option<&str> {
Some(&self.workspace_id) Some(self.request_client.workspace_id())
} }
fn kind(&self) -> &str { fn kind(&self) -> &str {
@@ -588,117 +589,6 @@ fn percent_encode_query(value: &str) -> String {
encoded encoded
} }
fn execute_runtime_owned_workspace_http(
base_url: &str,
workspace_id: &str,
runtime_id: &str,
worker_id: &str,
request_source_signer: Option<&RuntimeRequestSourceSigner>,
request_source_audience: Option<&str>,
request_timeout: Option<Duration>,
permission: &'static str,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !request.path.starts_with('/') || request.path.starts_with("//") {
return Err(WorkspaceClientError::InvalidPath(request.path));
}
let url = format!("{base_url}{}", request.path);
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
WorkspaceRequestMethod::Post => reqwest::Method::POST,
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
};
let client = reqwest::blocking::Client::builder()
.timeout(request_timeout)
.build()
.map_err(|error| {
WorkspaceClientError::Unavailable(format!(
"failed to build Workspace API HTTP client: {}",
reqwest_error_chain(&error)
))
})?;
let request_label = format!("{method} {}", request.path);
let body = request.body.unwrap_or_default();
let mut request_builder = client
.request(method.clone(), url)
.header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id);
if let Some(signer) = request_source_signer {
let audience = request_source_audience.ok_or_else(|| {
WorkspaceClientError::Request(
"runtime request proof audience is unavailable".to_owned(),
)
})?;
let proof = signer
.issue(
audience,
workspace_id,
Some(worker_id),
permission,
method.as_str(),
&request.path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !body.is_empty() {
request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
let response = request_builder
.send()
.map_err(|error| workspace_http_error(&request_label, "waiting for response", error))?;
let status = response.status().as_u16();
let body = response
.text()
.map_err(|error| workspace_http_error(&request_label, "reading response body", error))?;
Ok(WorkspaceResponse { status, body })
}
fn workspace_http_error(
request_label: &str,
stage: &str,
error: reqwest::Error,
) -> WorkspaceClientError {
let details = reqwest_error_chain(&error);
if error.is_timeout() {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} timed out while {stage}: {details}"
))
} else if error.is_connect() {
WorkspaceClientError::Unavailable(format!(
"Workspace API {request_label} could not connect while {stage}: {details}"
))
} else {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} transport failed while {stage}: {details}"
))
}
}
fn reqwest_error_chain(error: &reqwest::Error) -> String {
let mut details = error.to_string();
let mut source = std::error::Error::source(error);
for _ in 0..4 {
let Some(current) = source else {
break;
};
let current_text = current.to_string();
if !current_text.is_empty() && !details.ends_with(&current_text) {
details.push_str(": ");
details.push_str(&current_text);
}
source = std::error::Error::source(current);
}
details
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RuntimeWorkerMutationForwardError { pub enum RuntimeWorkerMutationForwardError {
#[error(transparent)] #[error(transparent)]
@@ -722,9 +612,9 @@ fn unix_now_seconds() -> u64 {
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::{ use crate::auth::{
WorkerMutationSourceExpectation, decode_runtime_request_source_claims, RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WorkerMutationSourceExpectation,
decode_worker_mutation_source_claims, request_body_digest, decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
verify_worker_mutation_source_proof, request_body_digest, verify_worker_mutation_source_proof,
}; };
#[test] #[test]
@@ -1132,7 +1022,12 @@ mod tests {
&identity, &identity,
scope, scope,
"worker-source", "worker-source",
RuntimeWorkspaceRequestClient::new(
"workspace-a",
format!("http://{address}"), format!("http://{address}"),
"runtime-a",
)
.with_runtime_request_source(&identity, "server-a"),
); );
let response = forwarder let response = forwarder
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker") .execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
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"));
}
}
+1 -3
View File
@@ -20,6 +20,7 @@ protocol = { workspace = true, features = ["json-schema"] }
client = { workspace = true } client = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } 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"] } uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true } session-metrics = { workspace = true }
arc-swap = "1.9.1" arc-swap = "1.9.1"
wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] }
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] } tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] } tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
futures-util = { version = "0.3", features = ["sink"] } futures-util = { version = "0.3", features = ["sink"] }
@@ -52,5 +52,3 @@ dotenv = "0.15.0"
futures = { workspace = true } futures = { workspace = true }
serial_test = "3.4.0" serial_test = "3.4.0"
tempfile = { workspace = true } tempfile = { workspace = true }
wat = "1.241.2"
yoi-plugin-pdk = { workspace = true }
+4 -1
View File
@@ -101,7 +101,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Send a run method // Send a run method
handle 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?; .await?;
// Wait for completion // Wait for completion
+78 -19
View File
@@ -22,7 +22,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use agen::Item; 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 agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
use async_trait::async_trait; use async_trait::async_trait;
use serde::Deserialize; use serde::Deserialize;
@@ -397,15 +400,19 @@ impl CompactWorkerInterceptor {
} }
#[async_trait] #[async_trait]
impl Interceptor for CompactWorkerInterceptor { impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction { async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
let context = context.items;
let records = self.usage_tracker.records(); let records = self.usage_tracker.records();
let estimate = agen::token_counter::total_tokens(context, &records); let estimate = agen::token_counter::total_tokens(context, &records);
if estimate.tokens > self.max_input_tokens { if estimate.tokens > self.max_input_tokens {
return PreRequestAction::Cancel(format!( return Ok(PreRequestAction::Cancel(format!(
"compact worker input occupancy exceeded {} tokens", "compact worker input occupancy exceeded {} tokens",
self.max_input_tokens self.max_input_tokens
)); )));
} }
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens); let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
@@ -413,25 +420,28 @@ impl Interceptor for CompactWorkerInterceptor {
.store(remaining, Ordering::Release); .store(remaining, Ordering::Release);
if let Some(item) = self.maybe_emit_warning(remaining) { if let Some(item) = self.maybe_emit_warning(remaining) {
self.usage_tracker.note_request(context.len() + 1); 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()); 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" { 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); let remaining = self.last_remaining_tokens.load(Ordering::Acquire);
if remaining > self.final_reserve_tokens { 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(), info.call.id.clone(),
"compact worker final reserve reached; do not perform more exploratory tool reads. Call `write_summary` now.", "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")]; let mut context = vec![Item::user_message("hello")];
assert!(matches!( 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 PreRequestAction::Continue
)); ));
tracker.record_usage(&make_usage(100)); tracker.record_usage(&make_usage(100));
assert!(matches!( 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 PreRequestAction::Continue
)); ));
tracker.record_usage(&make_usage(100)); tracker.record_usage(&make_usage(100));
@@ -481,7 +505,14 @@ mod tests {
// Two 100-token requests would exceed a cumulative 150-token cap, but // Two 100-token requests would exceed a cumulative 150-token cap, but
// current occupancy is still the latest 100-token measurement. // current occupancy is still the latest 100-token measurement.
assert!(matches!( 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 PreRequestAction::Continue
)); ));
} }
@@ -503,13 +534,27 @@ mod tests {
let mut context = vec![Item::user_message("hello")]; let mut context = vec![Item::user_message("hello")];
assert!(matches!( 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 PreRequestAction::Continue
)); ));
tracker.record_usage(&make_usage(100)); tracker.record_usage(&make_usage(100));
assert!(matches!( 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) PreRequestAction::ContinueWith(items)
if items.len() == 1 && items[0].as_text().unwrap_or_default().contains("write_summary") 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")]; let mut context = vec![Item::user_message("hello")];
assert!(matches!( 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 PreRequestAction::Continue
)); ));
tracker.record_usage(&make_usage(100)); tracker.record_usage(&make_usage(100));
assert!(matches!( 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") PreRequestAction::Cancel(message) if message.contains("occupancy")
)); ));
} }
File diff suppressed because it is too large Load Diff
+39 -11
View File
@@ -743,6 +743,7 @@ fn comm_info_from_spawned_child(child: &session_store::WorkerSpawnedChild) -> Co
target: rule.target.clone(), target: rule.target.clone(),
permission, permission,
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
}) })
}) })
.collect(); .collect();
@@ -779,10 +780,10 @@ async fn probe_socket(socket_path: &Path) -> LiveInfo {
loop { loop {
match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await { match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
Ok(Ok(Some(Event::Snapshot { Ok(Ok(Some(Event::Snapshot {
status: snapshot_status, state: snapshot_state,
.. ..
}))) => { }))) => {
status = Some(snapshot_status); status = Some(snapshot_state.catalog_status());
break; break;
} }
Ok(Ok(Some(Event::Alert(_)))) => continue, 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<()> { 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> { fn json_content<T: Serialize>(value: &T) -> Result<String, ToolError> {
@@ -1312,6 +1325,7 @@ mod tests {
target: root.path().to_path_buf(), target: root.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
active_child_segment, active_child_segment,
) )
@@ -1482,6 +1496,7 @@ mod tests {
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: protocol::Greeting { greeting: protocol::Greeting {
@@ -1494,7 +1509,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Idle, state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}) })
@@ -1517,6 +1532,7 @@ mod tests {
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: protocol::Greeting { greeting: protocol::Greeting {
@@ -1529,14 +1545,17 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Idle, state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}) })
.await .await
.unwrap(); .unwrap();
let method = reader.next::<Method>().await.unwrap().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); assert!(auto_run);
tx.send(message).await.unwrap(); tx.send(message).await.unwrap();
} else { } else {
@@ -1608,6 +1627,7 @@ mod tests {
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: protocol::Greeting { greeting: protocol::Greeting {
@@ -1620,7 +1640,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Idle, state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}) })
@@ -1634,6 +1654,7 @@ mod tests {
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: protocol::Greeting { greeting: protocol::Greeting {
@@ -1646,14 +1667,17 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Idle, state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}) })
.await .await
.unwrap(); .unwrap();
let method = reader.next::<Method>().await.unwrap().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); assert!(!auto_run);
tx.send(message).await.unwrap(); tx.send(message).await.unwrap();
} else { } else {
@@ -1738,6 +1762,7 @@ mod tests {
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: protocol::Greeting { greeting: protocol::Greeting {
@@ -1750,7 +1775,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Paused, state: WorkerStatus::Paused.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}) })
@@ -1772,6 +1797,7 @@ mod tests {
target: PathBuf::from("/tmp"), target: PathBuf::from("/tmp"),
permission: "read".into(), permission: "read".into(),
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
callback_address: PathBuf::from("/tmp/parent.sock"), callback_address: PathBuf::from("/tmp/parent.sock"),
} }
@@ -1790,6 +1816,8 @@ mod tests {
let _ = writer let _ = writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(
),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: protocol::Greeting { greeting: protocol::Greeting {
@@ -1802,7 +1830,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Idle, state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}) })
+4
View File
@@ -286,6 +286,7 @@ fn read_rule(target: PathBuf) -> ScopeRule {
target, target,
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
@@ -294,6 +295,7 @@ fn write_rule(target: PathBuf) -> ScopeRule {
target, target,
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
@@ -306,6 +308,7 @@ fn workspace_scope(
target: workspace_root.to_path_buf(), target: workspace_root.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
let deny = deny_write let deny = deny_write
.iter() .iter()
@@ -711,6 +714,7 @@ permission = "write"
target: target.to_path_buf(), target: target.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
+412 -144
View File
@@ -23,7 +23,14 @@ use agen::tool::ToolDefinition;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; 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. /// Stable source-qualified identifier for a feature module.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[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)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum FeatureHookPoint { pub enum FeatureHookPoint {
PreRequest, PromptSubmit,
PreLlmRequest,
PreToolCall, PreToolCall,
ToolResult, PostToolCall,
TurnEnd, AssistantTurnEnd,
RunExit,
RunCommitted,
BeforeSessionRewrite,
WorkerStopping,
} }
/// Serializable declaration of a tool contribution. The executable factory is /// 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. /// Background tasks are always Worker-managed and execute inside the owning
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] /// 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")] #[serde(rename_all = "snake_case")]
pub enum BackgroundTaskLifecycle { pub enum BackgroundTaskLifecycle {
DescriptorOnly, WorkerManaged,
HostManaged,
} }
/// Declaration for a feature-provided background task. /// Declaration for a feature-provided executable background task.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct BackgroundTaskDeclaration { pub struct BackgroundTaskDeclaration {
pub name: String, pub name: String,
pub description: String, pub description: String,
@@ -396,11 +409,11 @@ pub struct BackgroundTaskDeclaration {
} }
impl 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 { Self {
name: name.into(), name: name.into(),
description: description.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( fn mark_skipped(
&mut self, &mut self,
kind: FeatureContributionKind, kind: FeatureContributionKind,
@@ -881,46 +903,6 @@ fn reject_undeclared_contribution(
error 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. /// Diagnostic sink available to feature installers.
pub struct FeatureDiagnosticSink<'a> { pub struct FeatureDiagnosticSink<'a> {
report: &'a mut FeatureInstallReport, 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( pub fn add_pre_request(
&mut self, &mut self,
name: impl Into<String>, name: impl Into<String>,
hook: impl Hook<PreLlmRequest> + 'static, hook: impl Hook<PreLlmRequest> + 'static,
) -> Result<(), FeatureInstallError> { ) -> 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.require_declared(&declaration)?;
self.hook_builder.add_pre_llm_request(hook); self.hook_builder
self.report.installed_hooks.push(declaration); .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(()) Ok(())
} }
@@ -1059,10 +1100,25 @@ impl HookContributionRegistrar<'_> {
name: impl Into<String>, name: impl Into<String>,
hook: impl Hook<PreToolCall> + 'static, hook: impl Hook<PreToolCall> + 'static,
) -> Result<(), FeatureInstallError> { ) -> 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.require_declared(&declaration)?;
self.hook_builder.add_pre_tool_call(hook); self.hook_builder
self.report.installed_hooks.push(declaration); .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(()) Ok(())
} }
@@ -1071,10 +1127,25 @@ impl HookContributionRegistrar<'_> {
name: impl Into<String>, name: impl Into<String>,
hook: impl Hook<PostToolCall> + 'static, hook: impl Hook<PostToolCall> + 'static,
) -> Result<(), FeatureInstallError> { ) -> 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.require_declared(&declaration)?;
self.hook_builder.add_post_tool_call(hook); self.hook_builder
self.report.installed_hooks.push(declaration); .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(()) Ok(())
} }
@@ -1083,10 +1154,82 @@ impl HookContributionRegistrar<'_> {
name: impl Into<String>, name: impl Into<String>,
hook: impl Hook<OnTurnEnd> + 'static, hook: impl Hook<OnTurnEnd> + 'static,
) -> Result<(), FeatureInstallError> { ) -> 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.require_declared(&declaration)?;
self.hook_builder.add_on_turn_end(hook); self.hook_builder
self.report.installed_hooks.push(declaration); .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(()) 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> { pub struct BackgroundTaskRegistrar<'a> {
feature_id: &'a FeatureId, feature_id: &'a FeatureId,
declarations: &'a FeatureContributionDeclarations, declarations: &'a FeatureContributionDeclarations,
registry: &'a mut FeatureBackgroundTaskRegistryBuilder,
report: &'a mut FeatureInstallReport, report: &'a mut FeatureInstallReport,
} }
impl BackgroundTaskRegistrar<'_> { impl BackgroundTaskRegistrar<'_> {
pub fn declare( pub fn register(
&mut self, &mut self,
declaration: BackgroundTaskDeclaration, spec: BackgroundTaskSpec,
task: impl FeatureBackgroundTask + 'static,
) -> Result<(), FeatureInstallError> { ) -> Result<(), FeatureInstallError> {
if !self.declarations.contains_background_task(&declaration) { if !self
.declarations
.contains_background_task(&spec.declaration)
{
return Err(reject_undeclared_contribution( return Err(reject_undeclared_contribution(
self.feature_id, self.feature_id,
self.report, self.report,
FeatureContributionKind::BackgroundTask, FeatureContributionKind::BackgroundTask,
declaration.name, spec.declaration.name,
)); ));
} }
self.registry
.register(self.feature_id.clone(), spec.clone(), task)?;
if !self if !self
.report .report
.declared_background_tasks .declared_background_tasks
.iter() .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(()) Ok(())
} }
@@ -1330,15 +1480,17 @@ impl ProtocolProviderRegistrar<'_> {
} }
} }
for task in background_tasks { if let Some(task) = background_tasks.first() {
if !self let reason = format!(
.report "protocol provider background task `{}` has no executable Worker-managed handler",
.declared_background_tasks task.name
.iter() );
.any(|declared| declared.name == task.name) self.report.mark_skipped(
{ FeatureContributionKind::BackgroundTask,
self.report.declared_background_tasks.push(task); task.name.clone(),
} reason.clone(),
);
return Err(FeatureInstallError::InvalidDescriptor(reason));
} }
Ok(()) Ok(())
@@ -1352,6 +1504,7 @@ pub struct FeatureInstallContext<'a> {
pending_tools: &'a mut Vec<ToolDefinition>, pending_tools: &'a mut Vec<ToolDefinition>,
installed_tool_names: &'a mut HashMap<String, FeatureId>, installed_tool_names: &'a mut HashMap<String, FeatureId>,
hook_builder: &'a mut HookRegistryBuilder, hook_builder: &'a mut HookRegistryBuilder,
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
service_registry: &'a mut FeatureServiceRegistry, service_registry: &'a mut FeatureServiceRegistry,
report: &'a mut FeatureInstallReport, report: &'a mut FeatureInstallReport,
} }
@@ -1392,6 +1545,7 @@ impl FeatureInstallContext<'_> {
BackgroundTaskRegistrar { BackgroundTaskRegistrar {
feature_id: self.feature_id, feature_id: self.feature_id,
declarations: self.declarations, declarations: self.declarations,
registry: self.background_task_builder,
report: self.report, 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<'_> { pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
FeatureDiagnosticSink { FeatureDiagnosticSink {
report: self.report, report: self.report,
@@ -1440,6 +1582,7 @@ impl FeatureInstallContext<'_> {
pub struct FeatureRegistryInstallReport { pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>, pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry, pub services: FeatureServiceRegistry,
pub background_tasks: FeatureBackgroundTaskRegistry,
pub plan_error: Option<FeaturePlanError>, pub plan_error: Option<FeaturePlanError>,
} }
@@ -1795,7 +1938,7 @@ impl FeatureRegistryBuilder {
} }
/// Install modules into the existing Engine tool path and hook builder. /// Install modules into the existing Engine tool path and hook builder.
pub(crate) fn install_into_engine<C: LlmClient, A>( pub(crate) fn install_into_engine<C: LlmClient, A: Send + Sync>(
self, self,
worker: &mut Engine<C, Mutable, A>, worker: &mut Engine<C, Mutable, A>,
hook_builder: &mut HookRegistryBuilder, hook_builder: &mut HookRegistryBuilder,
@@ -1861,12 +2004,16 @@ impl FeatureRegistryBuilder {
return FeatureRegistryInstallReport { return FeatureRegistryInstallReport {
reports, reports,
services: FeatureServiceRegistry::default(), services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
plan_error: Some(error), plan_error: Some(error),
}; };
} }
}; };
let mut service_registry = FeatureServiceRegistry::default(); let mut service_registry = FeatureServiceRegistry::default();
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len()); 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 mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
let ordered_modules = plan let ordered_modules = plan
.ordered_indices .ordered_indices
@@ -1884,6 +2031,11 @@ impl FeatureRegistryBuilder {
for (module, descriptor) in ordered_modules { for (module, descriptor) in ordered_modules {
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor); let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
let mut report = FeatureInstallReport::new(&descriptor); let mut report = FeatureInstallReport::new(&descriptor);
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; let mut required_service_failed = false;
for requirement in descriptor.requires_services.iter().cloned() { for requirement in descriptor.requires_services.iter().cloned() {
@@ -1920,10 +2072,6 @@ impl FeatureRegistryBuilder {
continue; continue;
} }
for background_task in descriptor.background_tasks.iter().cloned() {
report.declared_background_tasks.push(background_task);
}
let install_result = { let install_result = {
let mut context = FeatureInstallContext { let mut context = FeatureInstallContext {
feature_id: &descriptor.id, feature_id: &descriptor.id,
@@ -1931,6 +2079,7 @@ impl FeatureRegistryBuilder {
pending_tools, pending_tools,
installed_tool_names: &mut installed_tool_names, installed_tool_names: &mut installed_tool_names,
hook_builder, hook_builder,
background_task_builder: &mut background_task_builder,
service_registry: &mut service_registry, service_registry: &mut service_registry,
report: &mut report, report: &mut report,
}; };
@@ -1940,21 +2089,84 @@ impl FeatureRegistryBuilder {
match install_result { match install_result {
Ok(()) => report.installed = true, Ok(()) => report.installed = true,
Err(error) => { 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 report
.diagnostics .diagnostics
.push(FeatureDiagnostic::error(error.to_string())); .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); reports.push(report);
} }
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 { FeatureRegistryInstallReport {
reports, reports,
services: service_registry, services: service_registry,
background_tasks: background_task_builder.build(),
plan_error: None, plan_error: None,
} }
} }
} }
}
/// Feature installation errors. /// Feature installation errors.
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -1996,9 +2208,10 @@ pub enum FeatureInstallError {
Install(String), Install(String),
} }
pub mod background;
pub mod builtin; pub mod builtin;
pub mod mcp; pub mod mcp;
pub mod plugin; pub(crate) mod session;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -2010,6 +2223,31 @@ mod tests {
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn worker_feature_composition_has_no_dynamic_plugin_install_path() {
let feature_source = include_str!("feature.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
let controller_source = include_str!("controller.rs")
.split("#[cfg(test)]")
.next()
.unwrap();
for forbidden in [
"pub mod plugin",
"plugin_tool_features_if_enabled",
"ResolvedPluginRecord",
"read_resolved_plugin_runtime_component",
"feature.plugins",
] {
assert!(
!feature_source.contains(forbidden) && !controller_source.contains(forbidden),
"dynamic Plugin install path returned through {forbidden}"
);
}
assert_eq!(FeatureId::builtin("task").as_str(), "builtin:task");
}
#[derive(Clone)] #[derive(Clone)]
struct DummyClient; struct DummyClient;
@@ -2398,13 +2636,9 @@ mod tests {
} }
#[test] #[test]
fn descriptor_contributions_are_recorded() { fn executable_contributions_are_recorded() {
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy") let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
.with_tool(ToolDeclaration::new("Dummy", "dummy tool")) .with_tool(ToolDeclaration::new("Dummy", "dummy tool"));
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
"daily",
"descriptor-only background task",
));
let mut hook_builder = HookRegistryBuilder::default(); let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new(); let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new() let report = FeatureRegistryBuilder::new()
@@ -2420,7 +2654,7 @@ mod tests {
let feature_report = &report.reports[0]; let feature_report = &report.reports[0];
assert!(feature_report.installed); assert!(feature_report.installed);
assert_eq!(feature_report.installed_tools, vec!["Dummy"]); 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] #[test]
@@ -2480,8 +2714,9 @@ mod tests {
}) })
.install_into_pending(&mut pending_tools, &mut hook_builder); .install_into_pending(&mut pending_tools, &mut hook_builder);
assert_eq!(pending_tools.len(), 1); assert!(pending_tools.is_empty());
assert!(report.reports[0].installed); assert!(!report.reports[0].installed);
assert!(report.reports[0].installed_tools.is_empty());
assert!(!report.reports[1].installed); assert!(!report.reports[1].installed);
assert!( assert!(
report.reports[1] report.reports[1]
@@ -2558,7 +2793,7 @@ mod tests {
"1.0.0", "1.0.0",
"startup-discovered service", "startup-discovered service",
)) ))
.with_background_task(BackgroundTaskDeclaration::descriptor_only( .with_background_task(BackgroundTaskDeclaration::worker_managed(
"provider-poller", "provider-poller",
"provider lifecycle poller", "provider lifecycle poller",
)) ))
@@ -2568,7 +2803,7 @@ mod tests {
} }
#[test] #[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( let provider = ProtocolProviderDeclaration::new(
ProviderId::builtin("dynamic-provider"), ProviderId::builtin("dynamic-provider"),
"test-protocol", "test-protocol",
@@ -2599,30 +2834,18 @@ mod tests {
.collect(); .collect();
let feature_report = &report.reports[0]; let feature_report = &report.reports[0];
assert!(feature_report.installed); assert!(!feature_report.installed);
assert_eq!(feature_report.installed_tools, vec!["DynamicTool"]); assert!(feature_report.installed_tools.is_empty());
assert_eq!(tool_names, vec!["DynamicTool"]); assert!(tool_names.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(feature_report.provided_services.len(), 1); assert!(feature_report.provided_services.is_empty());
assert_eq!( assert!(feature_report.declared_background_tasks.is_empty());
feature_report.provided_services[0].id,
ServiceId::builtin("dynamic-service")
);
assert_eq!(
feature_report.declared_background_tasks[0].name,
"provider-poller"
);
assert_eq!(feature_report.protocol_providers.len(), 1); assert_eq!(feature_report.protocol_providers.len(), 1);
assert_eq!( assert!(feature_report.diagnostics.iter().any(|diagnostic| {
feature_report.protocol_providers[0].state, diagnostic
ProtocolProviderLifecycleState::Ready .message
); .contains("has no executable Worker-managed handler")
assert!( }));
feature_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("startup discovery completed"))
);
} }
#[test] #[test]
@@ -2779,8 +3002,8 @@ mod tests {
async fn call( async fn call(
&self, &self,
_input: &crate::hook::ToolCallSummary, _input: &crate::hook::ToolCallSummary,
) -> crate::hook::HookPreToolAction { ) -> Result<crate::hook::HookPreToolAction, crate::hook::HookError> {
crate::hook::HookPreToolAction::Continue 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 { struct BackgroundFeature {
descriptor: FeatureDescriptor, descriptor: FeatureDescriptor,
task_name: &'static str, task_name: &'static str,
@@ -2818,12 +3054,22 @@ mod tests {
&self, &self,
context: &mut FeatureInstallContext<'_>, context: &mut FeatureInstallContext<'_>,
) -> Result<(), FeatureInstallError> { ) -> Result<(), FeatureInstallError> {
context let declaration = self
.background_tasks() .descriptor
.declare(BackgroundTaskDeclaration::descriptor_only( .background_tasks
.iter()
.find(|task| task.name == self.task_name)
.cloned()
.unwrap_or_else(|| {
BackgroundTaskDeclaration::worker_managed(
self.task_name, self.task_name,
"runtime background task", "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] #[tokio::test]
fn background_task_declaration_is_descriptor_contribution() { async fn executable_background_task_is_registered_in_worker_scope() {
let descriptor = FeatureDescriptor::builtin("background", "Background") let descriptor = FeatureDescriptor::builtin("background", "Background")
.with_background_task(BackgroundTaskDeclaration::descriptor_only( .with_background_task(BackgroundTaskDeclaration::worker_managed(
"declared-task", "declared-task",
"descriptor contribution", "descriptor contribution",
)); ));
let mut hook_builder = HookRegistryBuilder::default(); let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new(); let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::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); .install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.reports[0].installed); assert!(report.reports[0].installed);
assert_eq!( assert_eq!(
report.reports[0].declared_background_tasks[0].name, report.reports[0].declared_background_tasks[0].name,
"declared-task" "declared-task"
); );
assert!(report.reports[0].skipped.is_empty()); 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] #[test]
@@ -3118,7 +3383,10 @@ mod tests {
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin); assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
assert_eq!( assert_eq!(
hook_points, hook_points,
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall] vec![
FeatureHookPoint::PreLlmRequest,
FeatureHookPoint::PreToolCall
]
); );
assert!(descriptor.background_tasks.is_empty()); assert!(descriptor.background_tasks.is_empty());
assert!(descriptor.provides_services.is_empty()); assert!(descriptor.provides_services.is_empty());
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -8,7 +8,8 @@ pub mod flow_transition;
pub mod manage_workdir; pub mod manage_workdir;
pub mod manage_worker; pub mod manage_worker;
pub mod memory; pub mod memory;
pub mod memory_extract; pub(crate) mod memory_lifecycle;
pub mod memory_staging_output;
pub mod merge_request; pub mod merge_request;
pub mod objective; pub mod objective;
pub mod orchestration; pub mod orchestration;
@@ -19,8 +20,6 @@ pub mod ticket;
pub mod worker_observation; pub mod worker_observation;
pub mod workspace_worker_discovery; 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 task::{TaskFeature, task_tools_feature};
pub use ticket::{ pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access, TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
@@ -5,6 +5,8 @@
//! endpoints, credentials, materializer handles, and operation sessions stay //! endpoints, credentials, materializer handles, and operation sessions stay
//! behind [`WorkspaceClient`]. //! behind [`WorkspaceClient`].
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -12,11 +14,12 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest}; use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
use workdir::{ use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirSession, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
}; };
@@ -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 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 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."; 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."; 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 { pub struct ManageWorkdirFeature {
client: Arc<dyn WorkspaceClient>, 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 { impl ManageWorkdirFeature {
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self { 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> { 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 [ for (name, definition) in [
( (
LIST_TOOL, LIST_TOOL,
@@ -142,9 +180,21 @@ impl FeatureModule for ManageWorkdirFeature {
} }
} }
#[derive(Clone, Debug)] #[derive(Clone)]
struct WorkspaceHttpWorkdirBackend { struct WorkspaceHttpWorkdirBackend {
client: Arc<dyn WorkspaceClient>, 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. /// Worker-local Workdir handle whose operation authority remains in the Workspace Backend.
@@ -156,8 +206,6 @@ struct WorkspaceHttpWorkdirBackend {
pub struct WorkspaceAttachedWorkdirSession { pub struct WorkspaceAttachedWorkdirSession {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
workdir: Workdir, workdir: Workdir,
expected_session_fence: Option<String>,
delegations: Vec<workdir::WorkdirDelegationRequest>,
} }
impl WorkspaceAttachedWorkdirSession { impl WorkspaceAttachedWorkdirSession {
@@ -165,8 +213,6 @@ impl WorkspaceAttachedWorkdirSession {
Arc::new(Self { Arc::new(Self {
client, client,
workdir: Workdir::new("workspace-attachment"), 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", "/api/w/{}/workers/self/workdir-session/operations",
encode_path_segment(workspace_id) encode_path_segment(workspace_id)
), ),
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { operation }).map_err(
expected_session_fence: self.expected_session_fence.clone(), |error| {
delegations: self.delegations.clone(),
operation,
})
.map_err(|error| {
WorkdirError::Transport(format!( WorkdirError::Transport(format!(
"failed to encode Workspace Workdir operation: {error}" "failed to encode Workspace Workdir operation: {error}"
)) ))
})?, },
)?,
); );
let response = self let response = self
.client .client
@@ -241,57 +284,24 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
WorkdirSessionCapabilities::ALL WorkdirSessionCapabilities::ALL
} }
fn transports_delegation_context(&self) -> bool { async fn authorize_scope_path(
true &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, &self,
request: &workdir::WorkdirDelegationRequest, request: WorkdirScopeOverlapRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> { ) -> Result<bool, WorkdirError> {
let expected_session_fence = if let Some(fence) = &self.expected_session_fence { match self.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))? {
fence.clone() WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
} else { _ => Err(Self::mismatch("scope_rules_overlap")),
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)
} }
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
@@ -387,7 +397,21 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
impl WorkspaceHttpWorkdirBackend { impl WorkspaceHttpWorkdirBackend {
fn new(client: Arc<dyn WorkspaceClient>) -> Self { 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> { fn workspace_id(&self) -> Result<&str, ToolError> {
@@ -565,11 +589,26 @@ impl Tool for WorkspaceHttpWorkdirTool {
parse_input::<WorkdirCreateInput>(input_json)?, parse_input::<WorkdirCreateInput>(input_json)?,
ctx.call_id.to_string(), ctx.call_id.to_string(),
), ),
WorkdirOperation::Attach => self WorkdirOperation::Attach => {
let result = self
.backend .backend
.attach(parse_input::<WorkdirAttachInput>(input_json)?), .attach(parse_input::<WorkdirAttachInput>(input_json)?);
if result.is_ok()
&& let Some(after_attach) = &self.backend.after_workdir_attach
{
after_attach();
}
result
}
WorkdirOperation::Detach => { WorkdirOperation::Detach => {
let _input = parse_input::<WorkdirDetachInput>(input_json)?; 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() self.backend.detach()
} }
WorkdirOperation::Delete => self WorkdirOperation::Delete => self
@@ -765,6 +804,7 @@ struct WorkdirDeleteInput {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*; use super::*;
use crate::feature::{FeatureModule, FeatureRegistryBuilder}; use crate::feature::{FeatureModule, FeatureRegistryBuilder};
@@ -844,9 +884,9 @@ mod tests {
"repository_key": "main", "repository_key": "main",
"creation_selector": "refs/heads/main", "creation_selector": "refs/heads/main",
"creation_ref": "0123456789abcdef", "creation_ref": "0123456789abcdef",
"materializer_kind": "local_git_worktree", "materializer_kind": "runtime_git_clone",
"cleanup_target": { "cleanup_target": {
"kind": "git_worktree", "kind": "runtime_git_clone",
"working_directory_id": id, "working_directory_id": id,
"repository_key": "main" "repository_key": "main"
}, },
@@ -1155,6 +1195,7 @@ mod tests {
command: "true".to_string(), command: "true".to_string(),
timeout_secs: 120, timeout_secs: 120,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: Some("/worker-local/bash-output".into()), spill_dir: Some("/worker-local/bash-output".into()),
tool_call_id: Some("call-1".to_string()), 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] #[tokio::test]
async fn attached_session_preserves_typed_provider_validation_error() { async fn attached_session_preserves_typed_provider_validation_error() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response( let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response(
@@ -1298,73 +1262,51 @@ mod tests {
} }
#[tokio::test] #[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![ let client = Arc::new(RecordingWorkspaceClient::new(vec![
response(json!({"value": "attachment-fence"})), response(json!({ "operation": "authorize_scope" })),
response(json!({ "operation": "authorize_scope" })),
response(json!({ response(json!({
"operation": "stat", "operation": "stat",
"result": {"path": "", "kind": "directory", "size": 0} "result": {"path": "visible.txt", "kind": "file", "size": 8}
})),
response(json!({
"operation": "stat",
"result": {"path": "nested", "kind": "directory", "size": 0}
})),
response(json!({
"operation": "stat",
"result": {"path": "nested/file", "kind": "file", "size": 1}
})), })),
])); ]));
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle( let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle(
client.clone(), client.clone(),
)); ));
let outer = parent let scoped = broker
.delegate(workdir::WorkdirDelegationRequest { .scope(workdir::WorkdirToolScope {
rules: vec![workdir::WorkdirDelegationRule { rules: vec![workdir::WorkdirToolScopeRule {
target: workdir::WorkdirPath::new("").unwrap(), target: workdir::WorkdirPath::new("").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read, permission: workdir::WorkdirToolScopePermission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
cwd: workdir::WorkdirPath::new("").unwrap(), cwd: workdir::WorkdirPath::new("").unwrap(),
command: false,
}) })
.await .await
.unwrap(); .unwrap();
let nested = outer scoped
.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
.stat(StatRequest { .stat(StatRequest {
path: workdir::WorkdirPath::new("file").unwrap(), path: workdir::WorkdirPath::new("visible.txt").unwrap(),
}) })
.await .await
.unwrap(); .unwrap();
let requests = client.requests(); let requests = client.requests();
assert_eq!(requests.len(), 4); assert_eq!(requests.len(), 3);
let outer_validation: serde_json::Value = for request in requests {
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!( assert_eq!(
nested_validation["delegations"].as_array().unwrap().len(), request.path,
2 "/api/w/workspace%2Ftest/workers/self/workdir-session/operations"
); );
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(requests[3].body.as_deref().unwrap()).unwrap(); serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(body["delegations"].as_array().unwrap().len(), 2); assert!(body.get("delegations").is_none());
assert_eq!(body["delegations"][0]["rules"][0]["target"], ""); assert!(body.get("child").is_none());
assert_eq!(body["delegations"][1]["rules"][0]["target"], "nested"); assert!(body.get("expected_session_fence").is_none());
assert_eq!(body["operation"]["request"]["path"], "file"); }
} }
#[test] #[test]
@@ -1416,4 +1358,86 @@ mod tests {
assert!(client.requests().is_empty()); assert!(client.requests().is_empty());
assert!(parse_input::<WorkdirListInput>(r#"{"path":"/tmp"}"#).is_err()); 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);
}
} }
+329 -1
View File
@@ -18,8 +18,13 @@ use schemars::JsonSchema;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::json; use serde_json::json;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::worker::{ use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, SystemPromptContributionSource, WorkspaceClient, WorkspaceClientError, WorkspaceRequest,
WorkspaceRequestMethod,
}; };
#[derive(Clone, Debug)] #[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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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> { fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
let mut names = definitions let mut names = definitions
.into_iter() .into_iter()
@@ -368,6 +560,142 @@ mod tests {
.input_schema .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] #[test]
fn normal_workspace_memory_tools_do_not_include_staging_tools() { fn normal_workspace_memory_tools_do_not_include_staging_tools() {
let names = tool_names(workspace_http_memory_tools(test_client())); let names = tool_names(workspace_http_memory_tools(test_client()));
File diff suppressed because it is too large Load Diff
@@ -28,7 +28,7 @@ const FINISH_DESCRIPTION: &str =
"Finish Memory extraction after validating the number of candidates staged during this run."; "Finish Memory extraction after validating the number of candidates staged during this run.";
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct MemoryExtractState { pub(crate) struct MemoryStagingOutputState {
view: Arc<SessionCapture>, view: Arc<SessionCapture>,
workspace_client: Arc<dyn WorkspaceClient>, workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef, source: SourceRef,
@@ -37,7 +37,7 @@ pub(crate) struct MemoryExtractState {
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>, finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
} }
impl MemoryExtractState { impl MemoryStagingOutputState {
pub(crate) fn new( pub(crate) fn new(
view: SessionCapture, view: SessionCapture,
workspace_client: Arc<dyn WorkspaceClient>, workspace_client: Arc<dyn WorkspaceClient>,
@@ -70,22 +70,20 @@ impl MemoryExtractState {
} }
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct MemoryExtractFeature { pub(crate) struct MemoryStagingOutputFeature {
state: MemoryExtractState, state: MemoryStagingOutputState,
} }
impl MemoryExtractFeature { impl MemoryStagingOutputFeature {
pub(crate) fn new(state: MemoryExtractState) -> Self { pub(crate) fn new(state: MemoryStagingOutputState) -> Self {
Self { state } Self { state }
} }
} }
impl FeatureModule for MemoryExtractFeature { impl FeatureModule for MemoryStagingOutputFeature {
fn descriptor(&self) -> FeatureDescriptor { fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("memory-extract", "Memory Extract") FeatureDescriptor::builtin("memory-staging-output", "Memory Staging Output")
.with_description( .with_description("Restricted Memory staging output for an extraction Internal Worker.")
"Memory staging and extraction completion, independent from session exploration.",
)
.with_tool(ToolDeclaration::new( .with_tool(ToolDeclaration::new(
"StageMemoryCandidate", "StageMemoryCandidate",
STAGE_DESCRIPTION, 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 || { Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams)) let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams))
.unwrap_or_else(|_| serde_json::json!({})); .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 || { Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams)) let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams))
.unwrap_or_else(|_| serde_json::json!({})); .unwrap_or_else(|_| serde_json::json!({}));
@@ -157,7 +155,7 @@ struct FinishMemoryExtractionParams {
} }
struct StageMemoryCandidateTool { struct StageMemoryCandidateTool {
state: MemoryExtractState, state: MemoryStagingOutputState,
} }
#[async_trait] #[async_trait]
@@ -252,7 +250,7 @@ impl Tool for StageMemoryCandidateTool {
} }
struct FinishMemoryExtractionTool { struct FinishMemoryExtractionTool {
state: MemoryExtractState, state: MemoryStagingOutputState,
} }
#[async_trait] #[async_trait]
@@ -431,8 +429,8 @@ mod tests {
use super::*; use super::*;
fn state() -> MemoryExtractState { fn state() -> MemoryStagingOutputState {
MemoryExtractState::new( MemoryStagingOutputState::new(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]), SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
crate::worker::marker_workspace_client(None, "test-backend"), crate::worker::marker_workspace_client(None, "test-backend"),
SourceRef { SourceRef {
@@ -445,8 +443,8 @@ mod tests {
#[test] #[test]
fn memory_extract_declares_only_memory_mutation_tools() { fn memory_extract_declares_only_memory_mutation_tools() {
let descriptor = MemoryExtractFeature::new(state()).descriptor(); let descriptor = MemoryStagingOutputFeature::new(state()).descriptor();
assert_eq!(descriptor.id.as_str(), "builtin:memory-extract"); assert_eq!(descriptor.id.as_str(), "builtin:memory-staging-output");
assert_eq!( assert_eq!(
descriptor descriptor
.tools .tools

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