171 Commits
Author SHA1 Message Date
Hare fbbea1bf91 fix: scope compaction suppression to one run 2026-09-16 07:27:21 +09:00
Hare a0f8ee51a4 fix: guard automatic compaction retries 2026-09-16 07:16:31 +09:00
Hare ffc1933f06 fix: separate compaction metric correlation 2026-09-16 06:34:15 +09:00
Hare b6960878a6 fix: align compaction metric schema 2026-09-16 06:24:45 +09:00
Hare fef3b6f4a0 fix: preserve compaction metric precision and CAS category 2026-09-16 06:13:32 +09:00
Hare c0a73c12ec feat: record compaction lifecycle metrics 2026-09-16 06:01:04 +09:00
Hare f40714efc2 feat: add explicit session metrics export 2026-09-16 06:00:57 +09:00
Hare 07ed908ae6 test: cover compaction restart boundaries 2026-09-16 05:14:16 +09:00
Hare cfe772ea7d fix: fence compaction commit and status 2026-09-16 04:58:38 +09:00
Hare c0fe20e8a2 fix: serialize compaction pointer commits 2026-09-16 04:41:27 +09:00
Hare df22526a0d fix: render compaction on worker status 2026-09-16 04:25:37 +09:00
Hare 1d06d4b08a fix: keep compaction lifecycle runtime-only 2026-09-16 04:19:02 +09:00
Hare 4e7a314a00 fix: make compaction activation atomic 2026-09-16 03:44:48 +09:00
Hare 016dbd7cb1 fix: preserve worker run generations across restore 2026-09-15 01:17:30 +09:00
Hare 7210d3c202 fix: reject failed worker restores before attach 2026-09-15 00:47:12 +09:00
Hare 572204b49a fix: resolve CLI connections before async runtime 2026-09-15 00:28:25 +09:00
Hare 86dd67a24c fix: skip stopped worker protocol subscriptions 2026-09-14 23:31:14 +09:00
Hare beeba1fdfc fix: migrate legacy worker snapshots 2026-09-14 22:28:01 +09:00
Hare 41b7b289d0 fix: serialize Workdir lease admission with writes 2026-09-14 20:32:21 +09:00
Hare 24237249d1 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 20:05:34 +09:00
Hare e448073b98 fix: fence resolved Workdir lease aliases 2026-09-14 20:05:27 +09:00
Hare c08152d069 fix: share workspace creation wire contract 2026-09-14 19:45:18 +09:00
Hare 3995561220 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-468-remove-local-workspace-authority 2026-09-14 19:26:44 +09:00
Hare aea51caeb4 fix: resolve workspaces through backend repositories 2026-09-14 19:24:44 +09:00
Hare 3eca380bd8 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 19:12:17 +09:00
Hare 8a3e06bc81 feat: add selective Workdir symlink policies 2026-09-14 19:09:12 +09:00
Hare c4274c42cb fix: align plugin help with offline commands 2026-09-14 18:52:00 +09:00
Hare a61ad15767 refactor: remove ambient plugin authority 2026-09-14 18:40:42 +09:00
Hare 7f1e374fd7 fix: remove repository-local workspace selection 2026-09-14 18:31:04 +09:00
Hare e3f5445a02 fix: move workspace metadata authority to server database 2026-09-14 18:31:04 +09:00
Hare d2cb50d081 fix: fence runtime removal races and retries 2026-09-13 02:54:01 +09:00
Hare 6c609808c9 fix: use authoritative runtime removal result 2026-09-13 02:30:24 +09:00
Hare 2d4c7b383a feat: add guarded runtime removal operation 2026-09-13 02:30:15 +09:00
Hare c21ed7dff2 fix: preserve credential candidates across Workdir retries 2026-09-13 01:24:26 +09:00
Hare 448e392a0e fix: persist Workdir credential candidate snapshots 2026-09-13 01:14:39 +09:00
Hare d97c40d6af fix: reject plain HTTP repository sources 2026-09-13 01:12:01 +09:00
Hare 2d512b6be6 fix: remove stale HTTP repository access branch 2026-09-13 01:04:46 +09:00
Hare f061a95b48 fix: validate repository URI before source kind 2026-09-13 00:45:43 +09:00
Hare eefdef1bef fix: lock verified runtime public keys 2026-09-13 00:32:44 +09:00
Hare 7675f81999 fix: reject plain HTTP repository sources 2026-09-13 00:29:16 +09:00
Hare 8fb592071f fix: fence stale repository SSH probes 2026-09-13 00:27:32 +09:00
Hare 2528312142 fix: lock verified Runtime public keys in settings 2026-09-13 00:23:25 +09:00
Hare 08d7965ea8 fix: centralize repository access expiry 2026-09-13 00:20:25 +09:00
Hare e0badad91f fix: invalidate stale repository SSH probes 2026-09-13 00:17:12 +09:00
Hare f0a91ce2d8 fix: centralize repository access expiry 2026-09-13 00:11:03 +09:00
Hare 24cab83f48 test: cover repository credential rotate visibility 2026-09-13 00:05:14 +09:00
Hare 33a2b5d702 fix: unify workspace request routing 2026-09-12 23:49:13 +09:00
Hare 7f807004ad Update AGENTS.md 2026-09-12 22:47:35 +09:00
Hare 5564425488 fix: retry retained workdir removal 2026-09-12 13:30:25 +09:00
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
268 changed files with 59054 additions and 28569 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 に投げる 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
+51 -654
View File
@@ -2,15 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -177,12 +168,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arc-swap"
version = "1.9.1"
@@ -476,9 +461,6 @@ name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
dependencies = [
"allocator-api2",
]
[[package]]
name = "bytecount"
@@ -662,15 +644,6 @@ dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -765,15 +738,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpp_demangle"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253"
dependencies = [
"cfg-if",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -792,148 +756,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cranelift-assembler-x64"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7"
dependencies = [
"cranelift-entity",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-bitset"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69"
dependencies = [
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
"cranelift-codegen-shared",
"cranelift-control",
"cranelift-entity",
"cranelift-isle",
"gimli",
"hashbrown 0.17.1",
"libm",
"log",
"pulley-interpreter",
"regalloc2",
"rustc-hash",
"serde",
"smallvec",
"target-lexicon",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
"cranelift-srcgen",
"heck",
"pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518"
[[package]]
name = "cranelift-control"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b"
dependencies = [
"cranelift-bitset",
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-frontend"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a"
dependencies = [
"cranelift-codegen",
"log",
"smallvec",
"target-lexicon",
]
[[package]]
name = "cranelift-isle"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087"
[[package]]
name = "cranelift-native"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc"
dependencies = [
"cranelift-codegen",
"libc",
"target-lexicon",
]
[[package]]
name = "cranelift-srcgen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1374,18 +1196,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1788,18 +1598,6 @@ dependencies = [
"polyval",
]
[[package]]
name = "gimli"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"fnv",
"hashbrown 0.16.1",
"indexmap",
"stable_deref_trait",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -1922,11 +1720,6 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"serde",
"serde_core",
]
[[package]]
name = "hashlink"
@@ -2614,15 +2407,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "manifest"
version = "0.1.0"
@@ -2713,15 +2497,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memfd"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227"
dependencies = [
"rustix 1.1.4",
]
[[package]]
name = "memmap2"
version = "0.9.10"
@@ -3003,18 +2778,6 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"crc32fast",
"hashbrown 0.17.1",
"indexmap",
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
@@ -3410,18 +3173,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]]
name = "postscript"
version = "0.14.1"
@@ -3524,29 +3275,6 @@ dependencies = [
"unicase",
]
[[package]]
name = "pulley-interpreter"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f"
dependencies = [
"cranelift-bitset",
"log",
"pulley-macros",
"wasmtime-internal-core",
]
[[package]]
name = "pulley-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -3824,20 +3552,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "regalloc2"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186"
dependencies = [
"allocator-api2",
"bumpalo",
"hashbrown 0.17.1",
"log",
"rustc-hash",
"smallvec",
]
[[package]]
name = "regex"
version = "1.12.3"
@@ -3971,12 +3685,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -4216,10 +3924,6 @@ name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
@@ -4393,6 +4097,7 @@ dependencies = [
"serde",
"serde_json",
"session-store",
"tempfile",
]
[[package]]
@@ -4536,9 +4241,6 @@ name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]]
name = "socket2"
@@ -4771,12 +4473,6 @@ dependencies = [
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "target-triple"
version = "1.0.0"
@@ -5086,8 +4782,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.29.0",
"webpki-roots 0.26.11",
]
[[package]]
@@ -5257,6 +4957,16 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
@@ -5267,12 +4977,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
@@ -5382,6 +5095,8 @@ dependencies = [
"httparse",
"log",
"rand 0.9.4",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
]
@@ -5683,27 +5398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser 0.244.0",
]
[[package]]
name = "wasm-encoder"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6"
dependencies = [
"leb128fmt",
"wasmparser 0.248.0",
]
[[package]]
name = "wasm-encoder"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f"
dependencies = [
"leb128fmt",
"wasmparser 0.252.0",
"wasmparser",
]
[[package]]
@@ -5714,8 +5409,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder 0.244.0",
"wasmparser 0.244.0",
"wasm-encoder",
"wasmparser",
]
[[package]]
@@ -5743,286 +5438,6 @@ dependencies = [
"semver",
]
[[package]]
name = "wasmparser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.17.1",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "wasmparser"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c"
dependencies = [
"bitflags 2.11.0",
"indexmap",
"semver",
]
[[package]]
name = "wasmprinter"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183"
dependencies = [
"anyhow",
"termcolor",
"wasmparser 0.248.0",
]
[[package]]
name = "wasmtime"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527"
dependencies = [
"addr2line",
"async-trait",
"bitflags 2.11.0",
"bumpalo",
"cc",
"cfg-if",
"encoding_rs",
"libc",
"log",
"mach2",
"memfd",
"object",
"once_cell",
"postcard",
"pulley-interpreter",
"rustix 1.1.4",
"semver",
"serde",
"serde_derive",
"smallvec",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-component-macro",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
"wasmtime-internal-fiber",
"wasmtime-internal-jit-debug",
"wasmtime-internal-jit-icache-coherence",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
"wasmtime-internal-winch",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-environ"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71"
dependencies = [
"anyhow",
"cpp_demangle",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-entity",
"gimli",
"hashbrown 0.17.1",
"indexmap",
"log",
"object",
"postcard",
"rustc-demangle",
"semver",
"serde",
"serde_derive",
"sha2 0.10.9",
"smallvec",
"target-lexicon",
"wasm-encoder 0.248.0",
"wasmparser 0.248.0",
"wasmprinter",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
]
[[package]]
name = "wasmtime-internal-component-macro"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96c17f35fae2ab574667aba0c58fd56349a6f788ac42541a2e543116d5cfb91"
dependencies = [
"anyhow",
"proc-macro2",
"quote",
"syn 2.0.117",
"wasmtime-internal-component-util",
"wasmtime-internal-wit-bindgen",
"wit-parser 0.248.0",
]
[[package]]
name = "wasmtime-internal-component-util"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2eeb9b53222859e6f5dc73d2ccfb33254d672469cac11b693a71912e2f3817"
[[package]]
name = "wasmtime-internal-core"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c"
dependencies = [
"hashbrown 0.17.1",
"libm",
"serde",
]
[[package]]
name = "wasmtime-internal-cranelift"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b"
dependencies = [
"cfg-if",
"cranelift-codegen",
"cranelift-control",
"cranelift-entity",
"cranelift-frontend",
"cranelift-native",
"gimli",
"itertools",
"log",
"object",
"pulley-interpreter",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-fiber"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a"
dependencies = [
"cc",
"cfg-if",
"libc",
"rustix 1.1.4",
"wasmtime-environ",
"wasmtime-internal-versioned-export-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-jit-debug"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551"
dependencies = [
"cc",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6"
dependencies = [
"cfg-if",
"libc",
"wasmtime-internal-core",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-unwinder"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5"
dependencies = [
"cfg-if",
"cranelift-codegen",
"log",
"object",
"wasmtime-environ",
]
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "wasmtime-internal-winch"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "828b66175c54a0d00b4c1c1c76658d8aa73aeb9fa3553575c5eee56d40f2eb18"
dependencies = [
"cranelift-codegen",
"gimli",
"log",
"object",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-cranelift",
"winch-codegen",
]
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae00896ad9bef1b3ca6401ae9a841daa6f357dd91541b6baf87082946d1bde1"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"heck",
"indexmap",
"wit-parser 0.248.0",
]
[[package]]
name = "wast"
version = "252.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder 0.252.0",
]
[[package]]
name = "wat"
version = "1.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb"
dependencies = [
"wast",
]
[[package]]
name = "web-sys"
version = "0.3.94"
@@ -6120,6 +5535,24 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -6229,25 +5662,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89c09acfdfa281b3340e1e94ef3cf6618d69eab975280f881e154c29f49419c1"
dependencies = [
"cranelift-assembler-x64",
"cranelift-codegen",
"gimli",
"regalloc2",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -6511,7 +5925,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser 0.244.0",
"wit-parser",
]
[[package]]
@@ -6558,10 +5972,10 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"wasm-encoder 0.244.0",
"wasm-encoder",
"wasm-metadata",
"wasmparser 0.244.0",
"wit-parser 0.244.0",
"wasmparser",
"wit-parser",
]
[[package]]
@@ -6579,26 +5993,7 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser 0.244.0",
]
[[package]]
name = "wit-parser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856"
dependencies = [
"anyhow",
"hashbrown 0.17.1",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser 0.248.0",
"wasmparser",
]
[[package]]
@@ -6650,6 +6045,7 @@ dependencies = [
"serial_test",
"session-metrics",
"session-store",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"ticket",
@@ -6660,11 +6056,8 @@ dependencies = [
"tracing",
"tungstenite 0.28.0",
"uuid",
"wasmtime",
"wat",
"workdir",
"workspace-api",
"yoi-plugin-pdk",
]
[[package]]
@@ -6694,6 +6087,8 @@ dependencies = [
"tokio-tungstenite 0.29.0",
"toml",
"tower",
"tracing",
"tracing-subscriber",
"url",
"uuid",
"workdir",
@@ -6782,6 +6177,7 @@ dependencies = [
"tokio",
"toml",
"tui",
"uuid",
"worker",
"workspace-api",
]
@@ -6839,6 +6235,7 @@ dependencies = [
"toml",
"tower",
"tracing",
"tracing-subscriber",
"ts-rs",
"url",
"uuid",
+1
View File
@@ -132,6 +132,7 @@ tokio-tungstenite = "0.29"
tower = "0.5"
toml = "1.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
url = "2.5"
uuid = "1.23"
zeroize = "1"
+6 -3
View File
@@ -40,7 +40,7 @@ use tracing_subscriber::EnvFilter;
use agen::{
Engine, EngineRunExit, RunInterruptionReason,
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
llm_client::{
LlmClient,
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
#[async_trait]
impl Interceptor for ToolResultPrinterPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
let name = self
.call_names
.lock()
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
println!(" Result ({}): ✅ {}", name, info.result.summary);
}
PostToolAction::Continue
Ok(PostToolAction::Continue)
}
}
+336 -106
View File
@@ -15,8 +15,12 @@ use crate::{
},
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
interceptor::{
DefaultInterceptor, Interceptor, PostToolAction, PreRequestAction, PreToolAction,
PromptAction, ToolCallInfo, ToolResultInfo, TurnEndAction,
AssistantTurnEndContext, DefaultInterceptor, Interceptor, InterceptorCallId,
InterceptorCounter, InterceptorCounters, InterceptorError, InterceptorErrorCategory,
InterceptorFailure, InterceptorInvocation, InterceptorPhase, InterceptorRunId,
InterceptorTurnId, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, RunExitContext,
ToolCallInfo, ToolResultInfo, TurnEndAction,
},
llm_client::{
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream,
@@ -58,6 +62,9 @@ pub enum EngineError {
/// A durable-history observer rejected an item before it entered history.
#[error("History append failed: {0}")]
HistoryAppend(String),
/// A trusted host interceptor callback failed.
#[error(transparent)]
Interceptor(#[from] InterceptorFailure),
/// Tool terminalization lost its execution-attempt compare-and-set fence.
#[error("Tool execution attempt fence failed: {0}")]
ToolAttemptFence(String),
@@ -181,7 +188,7 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
/// Result of [`Engine::run`] or [`Engine::resume`].
///
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
pub struct EngineRunOutput<C: LlmClient, A = ()> {
pub struct EngineRunOutput<C: LlmClient, A: Send + Sync = ()> {
/// The Engine, now in Locked state.
pub engine: Engine<C, Locked, A>,
/// Outcome of the turn.
@@ -305,7 +312,7 @@ enum StreamCompletion {
Interrupted { reason: String },
}
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
/// LLM client
client: C,
/// Retry policy for opening an LLM response stream.
@@ -322,7 +329,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
/// Tool server handle
tool_server: ToolServerHandle,
/// Interceptor for control-flow decisions
interceptor: Box<dyn Interceptor>,
interceptor: Box<dyn Interceptor<A>>,
/// System prompt
system_prompt: Option<String>,
/// History length at lock time (only meaningful in Locked state)
@@ -341,6 +348,11 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
/// `max_turns` is enforced against this run-scoped count rather than the
/// cumulative `turn_count` above.
active_run_turn_count: Option<usize>,
/// Identity retained across pause/yield and resume.
active_run_id: Option<InterceptorRunId>,
next_run_id: u64,
interceptor_invocation_count: usize,
last_run_exit_observer_failure: Option<InterceptorFailure>,
/// LlmCall count (per-Engine running counter, monotonic). Unlike
/// `turn_count` this never collapses retries.
llm_call_count: usize,
@@ -421,21 +433,57 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
_state: PhantomData<(S, A)>,
}
impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
fn start_logical_run(&mut self) {
self.active_run_turn_count = Some(0);
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
self.last_run_exit_observer_failure = None;
}
fn ensure_logical_run(&mut self) {
self.active_run_turn_count.get_or_insert(0);
if self.active_run_id.is_none() {
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
}
}
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
if !matches!(
result,
Ok(EngineResult::Paused | EngineResult::Yielded) | Err(EngineError::PauseRequested)
) {
fn interceptor_invocation(
&mut self,
phase: InterceptorPhase,
turn_id: Option<usize>,
call_id: Option<InterceptorCallId>,
tool_call: usize,
) -> InterceptorInvocation {
let invocation = self.interceptor_invocation_count;
self.interceptor_invocation_count = self.interceptor_invocation_count.saturating_add(1);
InterceptorInvocation {
run_id: self
.active_run_id
.expect("logical run identity must exist before interception"),
turn_id: turn_id.map(|value| InterceptorTurnId(value as u64)),
call_id,
phase,
counters: InterceptorCounters {
invocation: InterceptorCounter::from_usize(invocation),
engine_turn: InterceptorCounter::from_usize(self.turn_count),
run_turn: InterceptorCounter::from_usize(
self.active_run_turn_count.unwrap_or_default(),
),
llm_call: InterceptorCounter::from_usize(self.llm_call_count),
tool_batch: InterceptorCounter::from_usize(self.tool_execution_batch_count),
tool_call: InterceptorCounter::from_usize(tool_call),
},
}
}
fn finish_logical_run(&mut self, exit: &EngineRunExit) {
if !matches!(exit, EngineRunExit::Paused | EngineRunExit::Yielded) {
self.active_run_turn_count = None;
self.active_run_id = None;
}
}
@@ -741,7 +789,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
/// The interceptor governs approval, skip, pause, and abort decisions
/// at key points in the execution loop. If not set, the default
/// interceptor is used (all Continue / Finish).
pub fn set_interceptor(&mut self, interceptor: impl Interceptor + 'static) {
pub fn set_interceptor(&mut self, interceptor: impl Interceptor<A> + 'static) {
self.interceptor = Box::new(interceptor);
}
@@ -842,6 +890,10 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
///
/// `Some` is retained only while Pause or Yield permits a later
/// [`resume`](Self::resume). Terminal outcomes return this to `None`.
pub fn last_run_exit_observer_failure(&self) -> Option<&InterceptorFailure> {
self.last_run_exit_observer_failure.as_ref()
}
pub fn active_run_turn_count(&self) -> Option<usize> {
self.active_run_turn_count
}
@@ -853,6 +905,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
/// [`resume`](Self::resume) starts a fresh budget.
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
self.active_run_turn_count = turn_count;
if turn_count.is_none() {
self.active_run_id = None;
} else if self.active_run_id.is_none() {
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
}
}
/// Get the current LlmCall count (per-Engine running counter, never
@@ -1078,24 +1137,28 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
request
}
/// Hooks: on_prompt_submit
///
async fn finalize_interruption<T>(
async fn finalize_run_exit(
&mut self,
result: Result<T, EngineError>,
) -> Result<T, EngineError> {
match result {
Ok(value) => Ok(value),
Err(err) => {
let reason = match &err {
EngineError::Aborted(reason) => reason.clone(),
EngineError::Cancelled => "Cancelled".to_string(),
_ => err.to_string(),
};
self.interceptor.on_abort(&reason).await;
Err(err)
}
history: &History<A>,
result: Result<EngineResult, EngineError>,
) -> EngineRunExit {
let exit = EngineRunExit::from(result);
let invocation = self.interceptor_invocation(InterceptorPhase::RunExit, None, None, 0);
self.last_run_exit_observer_failure = None;
if let Err(error) = self
.interceptor
.on_run_exit(RunExitContext {
invocation,
exit: &exit,
history: history.entries(),
})
.await
{
self.last_run_exit_observer_failure =
Some(InterceptorFailure::new(InterceptorPhase::RunExit, error));
}
self.finish_logical_run(&exit);
exit
}
/// Check for pending tool calls (for resuming from Pause)
@@ -1166,21 +1229,60 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new();
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
let expected_tool_use_id = tool_call.id.clone();
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
let invocation = self.interceptor_invocation(
InterceptorPhase::PreToolCall,
Some(self.turn_count.saturating_sub(1)),
Some(InterceptorCallId::Tool(expected_tool_use_id.clone())),
call_index,
);
let mut info = ToolCallInfo {
invocation,
history: history.entries(),
call: tool_call.clone(),
meta,
tool,
context,
};
match self.interceptor.pre_tool_call(&mut info).await {
let pre_tool_action =
self.interceptor
.pre_tool_call(&mut info)
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
error,
))
})?;
if info.call.id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"pre-tool interceptor changed immutable tool call identity",
),
)
.into());
}
match pre_tool_action {
PreToolAction::Continue => {}
PreToolAction::Skip => {
continue;
}
PreToolAction::SyntheticResult(result) => {
if result.tool_use_id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
let tool_call = info.call;
let mut context = info.context;
context.call_id = tool_call.id.clone();
@@ -1287,20 +1389,31 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let mut terminal_call_ids = HashSet::new();
let mut pause_requested = false;
let mut pause_deadline = None;
let mut batch_error = None;
let mut locally_enqueued_cancel = false;
for result in synthetic_results {
self.finalize_and_commit_tool_result(
history,
annotate,
result,
None,
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
)
.await?;
if let Err(error) = self
.finalize_and_commit_tool_result(
history,
annotate,
result,
None,
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
)
.await
&& batch_error.is_none()
{
batch_error = Some(error);
}
}
let mut futures = futures;
if batch_error.is_some() && !futures.is_empty() {
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
while !futures.is_empty() {
tokio::select! {
// If cancellation and a completed result are both ready, drain
@@ -1310,7 +1423,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
result = futures.next() => {
let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a result");
self.finalize_and_commit_tool_result(
if let Err(error) = self.finalize_and_commit_tool_result(
history,
annotate,
result,
@@ -1318,7 +1431,15 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
).await?;
).await {
if batch_error.is_none() {
batch_error = Some(error);
}
if !futures.is_empty() {
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
}
}
pause = self.pause_rx.recv(), if !pause_requested => {
if pause.is_some() {
@@ -1335,6 +1456,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
pause_deadline = None;
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
cancel = self.cancel_rx.recv() => {
if cancel.is_some() {
@@ -1380,7 +1502,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
result = futures.next() => {
let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a result");
self.finalize_and_commit_tool_result(
if let Err(error) = self.finalize_and_commit_tool_result(
history,
annotate,
result,
@@ -1388,7 +1510,11 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
).await?;
).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
}
_ = tokio::time::sleep_until(deadline) => break,
}
@@ -1402,7 +1528,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
if let Some(handle) = execution_handles.get(call_id) {
handle.force_close();
}
self.finalize_and_commit_tool_result(
if let Err(error) = self.finalize_and_commit_tool_result(
history,
annotate,
ToolResult::outcome_unknown(call_id),
@@ -1410,11 +1536,18 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
).await?;
).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
}
}
self.timeline.abort_current_block();
if let Some(error) = batch_error.take() {
return Err(error);
}
if pause_requested {
return Ok(ToolExecutionResult::Paused);
}
@@ -1423,6 +1556,16 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
}
// A result-biased ready sibling can empty the batch before the local
// cancel signal is selected. Never let that current-batch signal leak
// into the next run or resume call.
if locally_enqueued_cancel {
let _ = self.cancel_rx.try_recv();
}
if let Some(error) = batch_error {
self.timeline.abort_current_block();
return Err(error);
}
Ok(if pause_requested {
ToolExecutionResult::Paused
} else {
@@ -1466,31 +1609,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
let call_info = call_info_map.get(&tool_result.tool_use_id);
let mut abort_reason = None;
if let Some((tool_call, meta, tool, context)) = call_info {
let mut info = ToolResultInfo {
call: tool_call.clone(),
result: tool_result,
meta: meta.clone(),
tool: tool.clone(),
context: context.clone(),
};
match self.interceptor.post_tool_call(&mut info).await {
PostToolAction::Continue => {}
PostToolAction::Abort(reason) => {
abort_reason = Some(reason);
}
}
tool_result = info.result;
}
if tool_result.is_error && tool_result.disposition.is_success() {
tool_result.disposition = ToolResultDisposition::Error;
}
tool_result.is_error = !tool_result.disposition.is_success();
// Cap content only after post_tool_call so interceptors still observe
// the full payload and any content they inject is bounded too.
// Bound the terminal payload before committing it so the post-tool
// interceptor observes exactly the model-visible durable result.
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
self.tool_output_limits.as_ref(),
call_info,
@@ -1543,9 +1668,38 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
"Tool execution terminalized"
);
self.emit_tool_result(&tool_result);
if let Some(reason) = abort_reason {
return Err(EngineError::Aborted(reason));
if let Some((tool_call, meta, tool, context)) = call_info {
let invocation = self.interceptor_invocation(
InterceptorPhase::PostToolCall,
Some(self.turn_count.saturating_sub(1)),
Some(InterceptorCallId::Tool(tool_call.id.clone())),
context.call_index,
);
let info = ToolResultInfo {
invocation,
history: history.entries(),
call: tool_call.clone(),
result: tool_result,
meta: meta.clone(),
tool: tool.clone(),
context: context.clone(),
};
let post_tool_action =
self.interceptor
.post_tool_call(&info)
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PostToolCall,
error,
))
})?;
if let PostToolAction::Abort(reason) = post_tool_action {
return Err(EngineError::Aborted(reason));
}
}
Ok(true)
}
@@ -1608,11 +1762,25 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
// These are committed *before* the per-request clone so they
// participate in the LLM request below and get persisted by
// the caller that owns durable history.
let pending_invocation = self.interceptor_invocation(
InterceptorPhase::PendingHistoryAppends,
Some(current_turn),
None,
0,
);
let pending = self
.interceptor
.pending_history_appends()
.pending_history_appends(PendingHistoryAppendsContext {
invocation: pending_invocation,
history: history.entries(),
})
.await
.map_err(EngineError::HistoryAppend)?;
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PendingHistoryAppends,
error,
))
})?;
if !pending.is_empty() {
self.append_history_items(history, pending, annotate)?;
}
@@ -1679,7 +1847,27 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
// Interceptor: pre_llm_request
match self.interceptor.pre_llm_request(&mut request_context).await {
let request_invocation = self.interceptor_invocation(
InterceptorPhase::PreLlmRequest,
Some(current_turn),
Some(InterceptorCallId::Llm(self.llm_call_count as u64)),
0,
);
let pre_request_action = self
.interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: request_invocation,
items: &mut request_context,
history: history.entries(),
})
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PreLlmRequest,
error,
))
})?;
match pre_request_action {
PreRequestAction::Cancel(reason) => {
info!(reason = %reason, "Aborted by interceptor");
for cb in &self.turn_end_cbs {
@@ -1791,21 +1979,45 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let tool_calls = self.tool_call_collector.take_collected();
let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
let assistant_start = history.len();
self.append_history_items(history, assistant_items, annotate)?;
if tool_calls.is_empty() {
let turn_end_context = history.items_cloned();
match self.interceptor.on_turn_end(&turn_end_context).await {
TurnEndAction::Finish => {
return Ok(EngineResult::Finished);
}
TurnEndAction::ContinueWithMessages(additional) => {
self.append_history_items(history, additional, annotate)?;
let assistant_invocation = self.interceptor_invocation(
InterceptorPhase::AssistantTurnEnd,
Some(current_turn),
Some(InterceptorCallId::Llm(
self.llm_call_count.saturating_sub(1) as u64,
)),
0,
);
let assistant_turn_action = self
.interceptor
.on_assistant_turn_end(AssistantTurnEndContext {
invocation: assistant_invocation,
assistant_entries: &history.entries()[assistant_start..],
history: history.entries(),
tool_calls: &tool_calls,
})
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::AssistantTurnEnd,
error,
))
})?;
match assistant_turn_action {
TurnEndAction::Finish if tool_calls.is_empty() => {
return Ok(EngineResult::Finished);
}
TurnEndAction::Finish => {}
TurnEndAction::ContinueWithMessages(additional) => {
self.append_history_items(history, additional, annotate)?;
if tool_calls.is_empty() {
continue;
}
TurnEndAction::Pause => {
return Ok(EngineResult::Paused);
}
}
TurnEndAction::Pause => {
return Ok(EngineResult::Paused);
}
}
@@ -2098,7 +2310,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
}
impl<C: LlmClient, A> Engine<C, Mutable, A> {
impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
/// Create a new annotated Engine (in Mutable state).
pub fn new_annotated(client: C) -> Self {
let text_block_collector = TextBlockCollector::new();
@@ -2126,6 +2338,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len: 0,
turn_count: 0,
active_run_turn_count: None,
active_run_id: None,
next_run_id: 1,
interceptor_invocation_count: 0,
last_run_exit_observer_failure: None,
llm_call_count: 0,
tool_execution_batch_count: 0,
max_turns: None,
@@ -2401,6 +2617,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len,
turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count,
active_run_id: self.active_run_id,
next_run_id: self.next_run_id,
interceptor_invocation_count: self.interceptor_invocation_count,
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns,
@@ -2477,7 +2697,7 @@ impl<C: LlmClient> Engine<C, Mutable, ()> {
}
}
impl<C: LlmClient, A> Engine<C, Locked, A> {
impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
/// Execute a turn
///
/// Adds a new user message to history and sends a request to the LLM.
@@ -2488,9 +2708,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
user_input: impl Into<String>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit {
self.run_result_with_annotation(history, user_input.into(), annotate)
.await
.into()
let result = self
.run_result_with_annotation(history, user_input.into(), annotate)
.await;
self.finalize_run_exit(history, result).await
}
async fn run_result_with_annotation(
@@ -2501,13 +2722,26 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
) -> Result<EngineResult, EngineError> {
// Supplying new user input abandons any paused/yielded logical run.
self.active_run_turn_count = None;
self.active_run_id = None;
self.start_logical_run();
let mut user_item = Item::user_message(user_input);
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await {
PromptAction::Cancel(reason) => {
return self
.finalize_interruption(Err(EngineError::Aborted(reason)))
.await;
}
let invocation = self.interceptor_invocation(InterceptorPhase::PromptSubmit, None, None, 0);
let prompt_action = self
.interceptor
.on_prompt_submit(PromptSubmitContext {
invocation,
item: &mut user_item,
history: history.entries(),
})
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PromptSubmit,
error,
))
})?;
let extras = match prompt_action {
PromptAction::Cancel(reason) => return Err(EngineError::Aborted(reason)),
PromptAction::Continue => Vec::new(),
PromptAction::ContinueWith(items) => items,
};
@@ -2515,14 +2749,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
if !extras.is_empty() {
self.append_history_items(history, extras, annotate)?;
}
self.start_logical_run();
let result = match self.run_turn_loop(history, annotate).await {
match self.run_turn_loop(history, annotate).await {
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other,
};
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
}
}
/// Resume execution (from Paused state).
@@ -2531,9 +2761,8 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit {
self.resume_result_with_annotation(history, annotate)
.await
.into()
let result = self.resume_result_with_annotation(history, annotate).await;
self.finalize_run_exit(history, result).await
}
async fn resume_result_with_annotation(
@@ -2542,13 +2771,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineResult, EngineError> {
self.ensure_logical_run();
let result = match self.run_turn_loop(history, annotate).await {
match self.run_turn_loop(history, annotate).await {
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other,
};
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
}
}
/// Get the prefix length at lock time
@@ -2574,6 +2800,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
locked_prefix_len: 0,
turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count,
active_run_id: self.active_run_id,
next_run_id: self.next_run_id,
interceptor_invocation_count: self.interceptor_invocation_count,
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns,
+250 -28
View File
@@ -9,8 +9,202 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::Item;
use crate::engine::EngineRunExit;
use crate::history::HistoryEntry;
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
// =============================================================================
// Typed lifecycle metadata and failures
// =============================================================================
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
/// Stable category for the source of an interceptor failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterceptorErrorCategory {
Policy,
Dependency,
ContractViolation,
Internal,
}
impl std::fmt::Display for InterceptorErrorCategory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Policy => "policy",
Self::Dependency => "dependency",
Self::ContractViolation => "contract_violation",
Self::Internal => "internal",
})
}
}
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{category}: {diagnostic}")]
pub struct InterceptorError {
category: InterceptorErrorCategory,
diagnostic: String,
}
impl InterceptorError {
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
let mut diagnostic = diagnostic.into();
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
while !diagnostic.is_char_boundary(end) {
end -= 1;
}
diagnostic.truncate(end);
}
Self {
category,
diagnostic,
}
}
pub fn category(&self) -> InterceptorErrorCategory {
self.category
}
pub fn diagnostic(&self) -> &str {
&self.diagnostic
}
}
/// The lifecycle phase at which an interceptor callback executes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InterceptorPhase {
#[default]
PromptSubmit,
PendingHistoryAppends,
PreLlmRequest,
PreToolCall,
PostToolCall,
AssistantTurnEnd,
RunExit,
}
impl std::fmt::Display for InterceptorPhase {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::PromptSubmit => "prompt_submit",
Self::PendingHistoryAppends => "pending_history_appends",
Self::PreLlmRequest => "pre_llm_request",
Self::PreToolCall => "pre_tool_call",
Self::PostToolCall => "post_tool_call",
Self::AssistantTurnEnd => "assistant_turn_end",
Self::RunExit => "run_exit",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct InterceptorRunId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct InterceptorTurnId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InterceptorCallId {
Llm(u64),
Tool(String),
}
/// Saturating public counter used by interceptor contexts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct InterceptorCounter(u32);
impl InterceptorCounter {
pub fn from_usize(value: usize) -> Self {
Self(u32::try_from(value).unwrap_or(u32::MAX))
}
pub fn get(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct InterceptorCounters {
pub invocation: InterceptorCounter,
pub engine_turn: InterceptorCounter,
pub run_turn: InterceptorCounter,
pub llm_call: InterceptorCounter,
pub tool_batch: InterceptorCounter,
pub tool_call: InterceptorCounter,
}
/// Identity, phase, and bounded counters common to every lifecycle callback.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InterceptorInvocation {
pub run_id: InterceptorRunId,
pub turn_id: Option<InterceptorTurnId>,
pub call_id: Option<InterceptorCallId>,
pub phase: InterceptorPhase,
pub counters: InterceptorCounters,
}
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{phase} interceptor failed: {error}")]
pub struct InterceptorFailure {
phase: InterceptorPhase,
#[source]
error: InterceptorError,
}
impl InterceptorFailure {
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
Self { phase, error }
}
pub fn phase(&self) -> InterceptorPhase {
self.phase
}
pub fn error(&self) -> &InterceptorError {
&self.error
}
}
pub type InterceptorResult<T> = Result<T, InterceptorError>;
// =============================================================================
// Lifecycle Contexts
// =============================================================================
pub struct PromptSubmitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub item: &'a mut Item,
pub history: &'a [HistoryEntry<A>],
}
pub struct PendingHistoryAppendsContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
}
pub struct PreLlmRequestContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub items: &'a mut Vec<Item>,
pub history: &'a [HistoryEntry<A>],
}
pub struct AssistantTurnEndContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub assistant_entries: &'a [HistoryEntry<A>],
pub history: &'a [HistoryEntry<A>],
pub tool_calls: &'a [ToolCall],
}
pub struct RunExitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub exit: &'a EngineRunExit,
pub history: &'a [HistoryEntry<A>],
}
// =============================================================================
// Action Enums
// =============================================================================
@@ -86,9 +280,9 @@ pub enum PostToolAction {
/// Action at the end of a turn (when LLM produces no tool calls).
#[derive(Debug, Clone)]
pub enum TurnEndAction {
/// Turn is finished, return to caller.
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
Finish,
/// Continue with additional messages injected into history.
/// Commit additional messages, then continue through the natural next phase.
ContinueWithMessages(Vec<Item>),
/// Pause execution (can be resumed later).
Pause,
@@ -99,8 +293,9 @@ pub enum TurnEndAction {
// =============================================================================
/// Context for pre-tool-call decisions.
pub struct ToolCallInfo {
/// Tool call information (modifiable).
pub struct ToolCallInfo<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall,
/// Tool meta information.
pub meta: ToolMeta,
@@ -111,10 +306,11 @@ pub struct ToolCallInfo {
}
/// Context for post-tool-call decisions.
pub struct ToolResultInfo {
/// Original tool call.
pub struct ToolResultInfo<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall,
/// Tool execution result (modifiable).
/// Committed terminal tool execution result.
pub result: ToolResult,
/// Tool meta information.
pub meta: ToolMeta,
@@ -130,14 +326,22 @@ pub struct ToolResultInfo {
/// Intercepts the Engine execution loop at key decision points.
///
/// All methods have default implementations that let the Engine
/// proceed without intervention. Callers provide richer implementations for
/// approval flows, permission checks, etc.
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
/// keeping implementation failure separate from the method's control-flow
/// action. The Engine reports a failure as a typed run interruption annotated
/// with the exact [`InterceptorPhase`] that failed.
///
/// All methods have default implementations that let the Engine proceed
/// without intervention. Callers provide richer implementations for approval
/// flows, permission checks, and other trusted host adaptation.
#[async_trait]
pub trait Interceptor: Send + Sync {
/// Called after receiving user input, before adding to history.
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
PromptAction::Continue
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Called after receiving user input, before adding it to Engine history.
async fn on_prompt_submit(
&self,
_context: PromptSubmitContext<'_, A>,
) -> InterceptorResult<PromptAction> {
Ok(PromptAction::Continue)
}
/// Items that should be **committed to `engine.history`** just
@@ -158,7 +362,10 @@ pub trait Interceptor: Send + Sync {
/// reproducible per-request transformations (pruning, content
/// trimming, cache anchors) that depend only on the existing
/// history.
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, A>,
) -> InterceptorResult<Vec<Item>> {
Ok(Vec::new())
}
@@ -170,27 +377,42 @@ pub trait Interceptor: Send + Sync {
/// If an interceptor derives a human/model-visible nudge from the current
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
/// commits it to history before the request is sent.
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
PreRequestAction::Continue
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
Ok(PreRequestAction::Continue)
}
/// Called before each tool is executed.
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
PreToolAction::Continue
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, A>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::Continue)
}
/// Called after each tool completes.
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
PostToolAction::Continue
/// Called after each tool reaches one terminal result and that result is committed.
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, A>,
) -> InterceptorResult<PostToolAction> {
Ok(PostToolAction::Continue)
}
/// Called when a turn ends with no tool calls.
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
TurnEndAction::Finish
/// Called after every terminal assistant response is committed and before
/// the Engine decides whether to execute tools, continue, or finish.
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, A>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Finish)
}
/// Called when execution is interrupted (abort or cancel).
async fn on_abort(&self, _reason: &str) {}
/// Called once for the terminal outcome of each public run or resume call.
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
Ok(())
}
}
/// Default interceptor: no intervention. Engine proceeds through the loop
@@ -198,4 +420,4 @@ pub trait Interceptor: Send + Sync {
pub(crate) struct DefaultInterceptor;
#[async_trait]
impl Interceptor for DefaultInterceptor {}
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
+7 -1
View File
@@ -26,7 +26,13 @@ pub use engine::{
};
pub use handler::ToolUseBlockStart;
pub use history::{History, HistoryEntry};
pub use interceptor::Interceptor;
pub use interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
};
pub use message::{ContentPart, Item, Message, Role};
pub use tool::{
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
+126
View File
@@ -1,8 +1,15 @@
mod common;
use agen::interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use async_trait::async_trait;
use common::MockLlmClient;
use std::sync::{Arc, Mutex};
fn completed_text_events(text: &str) -> Vec<Event> {
vec![
@@ -47,6 +54,125 @@ async fn run_preserves_item_annotations_without_projecting_them() {
assert_eq!(history.items_cloned().len(), 2);
}
#[derive(Clone)]
struct AnnotationObservingInterceptor {
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
}
impl AnnotationObservingInterceptor {
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
self.observed.lock().unwrap().push((
invocation.clone(),
history
.iter()
.map(|entry| entry.annotation.clone())
.collect(),
));
}
}
#[async_trait]
impl Interceptor<String> for AnnotationObservingInterceptor {
async fn on_prompt_submit(
&self,
context: PromptSubmitContext<'_, String>,
) -> InterceptorResult<PromptAction> {
self.record(&context.invocation, context.history);
Ok(PromptAction::Continue)
}
async fn pending_history_appends(
&self,
context: PendingHistoryAppendsContext<'_, String>,
) -> InterceptorResult<Vec<Item>> {
self.record(&context.invocation, context.history);
Ok(Vec::new())
}
async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, String>,
) -> InterceptorResult<PreRequestAction> {
self.record(&context.invocation, context.history);
Ok(PreRequestAction::Continue)
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, String>,
) -> InterceptorResult<TurnEndAction> {
assert_eq!(context.assistant_entries.len(), 1);
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
self.record(&context.invocation, context.history);
Ok(TurnEndAction::Finish)
}
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
self.record(&context.invocation, context.history);
Ok(())
}
}
#[tokio::test]
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let observed = Arc::new(Mutex::new(Vec::new()));
engine.set_interceptor(AnnotationObservingInterceptor {
observed: observed.clone(),
});
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = if item.is_assistant_message() {
"assistant"
} else {
"user"
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await;
assert!(matches!(output.result, agen::EngineRunExit::Finished));
let observed = observed.lock().unwrap();
let phases: Vec<_> = observed
.iter()
.map(|(invocation, _)| invocation.phase)
.collect();
assert_eq!(
phases,
[
InterceptorPhase::PromptSubmit,
InterceptorPhase::PendingHistoryAppends,
InterceptorPhase::PreLlmRequest,
InterceptorPhase::AssistantTurnEnd,
InterceptorPhase::RunExit,
]
);
assert!(
observed
.iter()
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
);
assert_eq!(
observed
.iter()
.map(|(invocation, _)| invocation.counters.invocation.get())
.collect::<Vec<_>>(),
[0, 1, 2, 3, 4]
);
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[1].1, ["1:user"]);
assert_eq!(observed[2].1, ["1:user"]);
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
}
#[test]
fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]);
+519 -10
View File
@@ -10,9 +10,16 @@ use std::sync::{Arc, Mutex};
use agen::Item;
use agen::interceptor::{
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
InterceptorPhase as InterceptorPoint, InterceptorResult, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES,
PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext, PreRequestAction,
PreToolAction, PromptAction, PromptSubmitContext, RunExitContext, ToolCallInfo, ToolResultInfo,
TurnEndAction,
};
use agen::llm_client::{
ClientError, LlmClient, Request, ResponseStream,
event::{Event, ResponseStatus, StatusEvent},
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
use async_trait::async_trait;
@@ -613,12 +620,15 @@ struct YieldOnce {
#[async_trait]
impl Interceptor for YieldOnce {
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreRequestAction::Yield
} else {
PreRequestAction::Continue
}
})
}
}
@@ -628,12 +638,15 @@ struct PauseToolOnce {
#[async_trait]
impl Interceptor for PauseToolOnce {
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreToolAction::Pause
} else {
PreToolAction::Continue
}
})
}
}
@@ -643,13 +656,509 @@ struct ContinueTurnOnce {
#[async_trait]
impl Interceptor for ContinueTurnOnce {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
} else {
TurnEndAction::Finish
})
}
}
#[derive(Debug, Clone)]
struct FailingLifecycleInterceptor {
failure: InterceptorPoint,
calls: Arc<Mutex<Vec<InterceptorPoint>>>,
}
impl FailingLifecycleInterceptor {
fn new(failure: InterceptorPoint) -> Self {
Self {
failure,
calls: Arc::new(Mutex::new(Vec::new())),
}
}
fn record<T>(&self, point: InterceptorPoint, action: T) -> InterceptorResult<T> {
self.calls.lock().unwrap().push(point);
if self.failure == point {
Err(InterceptorError::new(
InterceptorErrorCategory::Policy,
format!("{point} rejected"),
))
} else {
Ok(action)
}
}
fn calls(&self) -> Vec<InterceptorPoint> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl Interceptor for FailingLifecycleInterceptor {
async fn on_prompt_submit(
&self,
_context: PromptSubmitContext<'_, ()>,
) -> InterceptorResult<PromptAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PromptSubmit, PromptAction::Continue)
}
async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, ()>,
) -> InterceptorResult<Vec<Item>> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PendingHistoryAppends, Vec::new())
}
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PreLlmRequest, PreRequestAction::Continue)
}
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PreToolCall, PreToolAction::Continue)
}
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PostToolCall, PostToolAction::Continue)
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
tokio::task::yield_now().await;
assert!(context.history.ends_with(context.assistant_entries));
if !context.tool_calls.is_empty() {
assert_eq!(
context
.assistant_entries
.iter()
.filter(|entry| matches!(&entry.item, Item::ToolCall { .. }))
.count(),
context.tool_calls.len()
);
}
self.record(InterceptorPoint::AssistantTurnEnd, TurnEndAction::Finish)
}
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::RunExit, ())
}
}
fn expected_interceptor_calls(failure: InterceptorPoint) -> Vec<InterceptorPoint> {
use InterceptorPoint as Point;
let mut calls = match failure {
Point::PromptSubmit => vec![Point::PromptSubmit],
Point::PendingHistoryAppends => {
vec![Point::PromptSubmit, Point::PendingHistoryAppends]
}
Point::PreLlmRequest => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
],
Point::PreToolCall => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
Point::PreToolCall,
],
Point::PostToolCall => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
Point::PreToolCall,
Point::PostToolCall,
],
Point::AssistantTurnEnd => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
],
Point::RunExit => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
],
};
calls.push(Point::RunExit);
calls
}
#[tokio::test]
async fn interceptor_failures_are_typed_and_terminal_observer_preserves_original_exit() {
use InterceptorPoint as Point;
for failure_point in [
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::PreToolCall,
Point::PostToolCall,
Point::AssistantTurnEnd,
Point::RunExit,
] {
let interceptor = FailingLifecycleInterceptor::new(failure_point);
let needs_tool = matches!(failure_point, Point::PreToolCall | Point::PostToolCall);
let events = if needs_tool {
vec![
Event::tool_use_start(0, "call-1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
} else {
completed_text_events()
};
let mut engine = Engine::new(MockLlmClient::new(events));
engine.register_tool(CountingTool::new("count_tool").definition());
engine.set_interceptor(interceptor.clone());
let mut history = History::new();
let mut engine = engine.lock(&history);
let exit = engine.run(&mut history, "test").await;
let failure = if failure_point == Point::RunExit {
assert!(matches!(exit, EngineRunExit::Finished));
engine
.last_run_exit_observer_failure()
.expect("terminal observer diagnostic should be retained")
} else {
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(failure),
)) = &exit
else {
panic!("expected typed interceptor interruption at {failure_point}, got {exit:?}");
};
failure
};
assert_eq!(failure.phase(), failure_point);
assert_eq!(
failure.error().diagnostic(),
format!("{failure_point} rejected")
);
assert_eq!(
interceptor.calls(),
expected_interceptor_calls(failure_point)
);
if failure_point == Point::PostToolCall {
assert!(
history
.items()
.any(|item| matches!(item, Item::ToolResult { .. })),
"post-tool failure must not precede terminal output commit"
);
}
}
}
#[test]
fn interceptor_error_keeps_typed_category_and_bounded_utf8_diagnostic() {
let error = InterceptorError::new(
InterceptorErrorCategory::Dependency,
"".repeat(MAX_INTERCEPTOR_DIAGNOSTIC_BYTES),
);
assert_eq!(error.category(), InterceptorErrorCategory::Dependency);
assert!(error.diagnostic().len() <= MAX_INTERCEPTOR_DIAGNOSTIC_BYTES);
assert!(
error
.diagnostic()
.is_char_boundary(error.diagnostic().len())
);
}
struct FailingRunExitObserver {
pause: bool,
}
#[async_trait]
impl Interceptor for FailingRunExitObserver {
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
Ok(if self.pause {
TurnEndAction::Pause
} else {
TurnEndAction::Finish
})
}
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
"terminal audit unavailable",
))
}
}
#[tokio::test]
async fn terminal_observer_failure_preserves_paused_and_interrupted_exits() {
let mut paused_engine = Engine::new(MockLlmClient::new(completed_text_events()));
paused_engine.set_interceptor(FailingRunExitObserver { pause: true });
let mut paused_history = History::new();
let mut paused_engine = paused_engine.lock(&paused_history);
assert!(matches!(
paused_engine.run(&mut paused_history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(
paused_engine
.last_run_exit_observer_failure()
.expect("paused observer diagnostic")
.error()
.category(),
InterceptorErrorCategory::Dependency
);
let mut interrupted_engine = Engine::new(MockLlmClient::new(completed_text_events()));
interrupted_engine.set_max_turns(Some(0));
interrupted_engine.set_interceptor(FailingRunExitObserver { pause: false });
let mut interrupted_history = History::new();
let mut interrupted_engine = interrupted_engine.lock(&interrupted_history);
assert!(matches!(
interrupted_engine
.run(&mut interrupted_history, "limit")
.await,
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(
interrupted_engine
.last_run_exit_observer_failure()
.expect("interrupted observer diagnostic")
.phase(),
InterceptorPoint::RunExit
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalMode {
Finish,
PauseOnce,
Yield,
}
#[derive(Debug, Clone)]
struct RecordingTerminalInterceptor {
mode: TerminalMode,
assistant_turns: Arc<AtomicUsize>,
exits: Arc<Mutex<Vec<&'static str>>>,
}
impl RecordingTerminalInterceptor {
fn new(mode: TerminalMode) -> Self {
Self {
mode,
assistant_turns: Arc::new(AtomicUsize::new(0)),
exits: Arc::new(Mutex::new(Vec::new())),
}
}
fn exits(&self) -> Vec<&'static str> {
self.exits.lock().unwrap().clone()
}
}
#[async_trait]
impl Interceptor for RecordingTerminalInterceptor {
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
Ok(if self.mode == TerminalMode::Yield {
PreRequestAction::Yield
} else {
PreRequestAction::Continue
})
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
assert!(!context.assistant_entries.is_empty());
assert!(
context.history.ends_with(context.assistant_entries),
"assistant-turn callback must observe committed terminal items"
);
let turn = self.assistant_turns.fetch_add(1, Ordering::SeqCst);
Ok(if self.mode == TerminalMode::PauseOnce && turn == 0 {
TurnEndAction::Pause
} else {
TurnEndAction::Finish
})
}
async fn on_run_exit(&self, context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
let kind = match context.exit {
EngineRunExit::Finished => "finished",
EngineRunExit::Paused => "paused",
EngineRunExit::Yielded => "yielded",
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => "limit",
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded) => "context",
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => "cancelled",
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(_)) => "unexpected",
};
self.exits.lock().unwrap().push(kind);
Ok(())
}
}
#[derive(Clone)]
struct ContextWindowClient;
#[async_trait]
impl LlmClient for ContextWindowClient {
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
Err(ClientError::ContextWindowExceeded)
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
#[tokio::test]
async fn terminal_observer_runs_once_for_every_exit_and_interruption_kind() {
let finished = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(finished.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "finish").await,
EngineRunExit::Finished
));
assert_eq!(finished.exits(), ["finished"]);
let yielded = RecordingTerminalInterceptor::new(TerminalMode::Yield);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(yielded.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "yield").await,
EngineRunExit::Yielded
));
assert_eq!(yielded.exits(), ["yielded"]);
let limited = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(0));
engine.set_interceptor(limited.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "limit").await,
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(limited.exits(), ["limit"]);
let cancelled = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(cancelled.clone());
engine.cancel();
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "cancel").await,
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
));
assert_eq!(cancelled.exits(), ["cancelled"]);
let context = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(ContextWindowClient);
engine.set_interceptor(context.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "context").await,
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded)
));
assert_eq!(context.exits(), ["context"]);
let unexpected = FailingLifecycleInterceptor::new(InterceptorPoint::PromptSubmit);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(unexpected.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "fail").await,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::Interceptor(
_
)))
));
assert_eq!(
unexpected
.calls()
.iter()
.filter(|point| **point == InterceptorPoint::RunExit)
.count(),
1
);
}
#[tokio::test]
async fn terminal_observer_does_not_duplicate_on_resume() {
let interceptor = RecordingTerminalInterceptor::new(TerminalMode::PauseOnce);
let first_response = vec![
Event::tool_use_start(0, "call-1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![first_response, completed_text_events()]);
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client);
engine.register_tool(tool.definition());
engine.set_interceptor(interceptor.clone());
let mut history = History::new();
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(interceptor.exits(), ["paused"]);
assert_eq!(
tool.call_count(),
0,
"pause must retain the pending tool phase"
);
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Finished
));
assert_eq!(interceptor.exits(), ["paused", "finished"]);
assert_eq!(
tool.call_count(),
1,
"resume must execute the retained tool once"
);
}
#[tokio::test]
+240 -40
View File
@@ -6,13 +6,18 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
use agen::interceptor::{
Interceptor, InterceptorError, InterceptorErrorCategory, InterceptorPhase, InterceptorResult,
PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
ToolResultDisposition,
};
use agen::{Engine, History, Item, ToolExecutionPolicy};
use agen::{
Engine, EngineError, EngineRunExit, History, Item, RunInterruptionReason, ToolExecutionPolicy,
};
use async_trait::async_trait;
mod common;
@@ -905,24 +910,30 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
#[async_trait]
impl Interceptor for ContextPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
self.pre_contexts.lock().unwrap().push(info.context.clone());
match info.call.name.as_str() {
Ok(match info.call.name.as_str() {
"skip_tool" => PreToolAction::Skip,
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
&info.call.id,
ToolOutput::from("synthetic result".to_string()),
)),
_ => PreToolAction::Continue,
}
})
}
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
self.post_contexts
.lock()
.unwrap()
.push(info.context.clone());
PostToolAction::Continue
Ok(PostToolAction::Continue)
}
}
@@ -994,12 +1005,15 @@ async fn test_before_tool_call_skip() {
#[async_trait]
impl Interceptor for BlockingPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
if info.call.name == "blocked_tool" {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if info.call.name == "blocked_tool" {
PreToolAction::Skip
} else {
PreToolAction::Continue
}
})
}
}
@@ -1021,9 +1035,9 @@ async fn test_before_tool_call_skip() {
);
}
/// Hook: post_tool_call - verify that results can be modified
/// Hook: post_tool_call - verify that the committed terminal result is observed.
#[tokio::test]
async fn test_post_tool_call_modification() {
async fn test_post_tool_call_observes_committed_result() {
// Prepare responses for multiple requests
let client = MockLlmClient::with_responses(vec![
// First request: tool call
@@ -1074,40 +1088,51 @@ async fn test_post_tool_call_modification() {
engine.register_tool(simple_tool_definition());
// Policy to modify results
struct ModifyingPolicy {
modified_content: Arc<std::sync::Mutex<Option<String>>>,
// Policy to observe the committed terminal result.
struct ObservingPolicy {
observed_content: Arc<std::sync::Mutex<Option<String>>>,
}
#[async_trait]
impl Interceptor for ModifyingPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
info.result.summary = format!("[Modified] {}", info.result.summary);
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone());
PostToolAction::Continue
impl Interceptor for ObservingPolicy {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
assert_eq!(info.invocation.phase, InterceptorPhase::PostToolCall);
assert_eq!(
info.invocation.call_id,
Some(agen::InterceptorCallId::Tool(info.call.id.clone()))
);
assert!(matches!(
info.history.last().map(|entry| &entry.item),
Some(Item::ToolResult { call_id, .. }) if call_id == &info.call.id
));
*self.observed_content.lock().unwrap() = Some(info.result.summary.clone());
Ok(PostToolAction::Continue)
}
}
let modified_content = Arc::new(std::sync::Mutex::new(None));
engine.set_interceptor(ModifyingPolicy {
modified_content: modified_content.clone(),
let observed_content = Arc::new(std::sync::Mutex::new(None));
engine.set_interceptor(ObservingPolicy {
observed_content: observed_content.clone(),
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run(&mut history, "Test modification").await;
let result = engine.run(&mut history, "Test observation").await;
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
// Verify hook was called and content was modified
let content = modified_content.lock().unwrap().clone();
assert!(content.is_some(), "Hook should have been called");
assert!(
content.unwrap().contains("[Modified]"),
"Result should be modified"
);
// Verify the interceptor observed the exact committed result.
let observed = observed_content.lock().unwrap().clone();
assert_eq!(observed.as_deref(), Some("Original Result"));
assert!(history.items().any(|item| matches!(
item,
Item::ToolResult { summary, .. } if summary == "Original Result"
)));
}
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
@@ -1143,11 +1168,14 @@ async fn test_before_tool_call_synthetic_result_committed() {
#[async_trait]
impl Interceptor for SyntheticPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
PreToolAction::SyntheticResult(ToolResult::error(
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::SyntheticResult(ToolResult::error(
info.call.id.clone(),
"permission denied",
))
)))
}
}
@@ -1167,6 +1195,80 @@ async fn test_before_tool_call_synthetic_result_committed() {
)));
}
#[derive(Clone, Copy)]
enum InvalidIdentityMode {
ContinuedCall,
SyntheticResult,
}
struct InvalidIdentityPolicy(InvalidIdentityMode);
#[async_trait]
impl Interceptor for InvalidIdentityPolicy {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
assert_eq!(info.invocation.phase, InterceptorPhase::PreToolCall);
assert_eq!(
info.invocation.call_id,
Some(agen::InterceptorCallId::Tool("call_1".to_string()))
);
assert!(matches!(
info.history.last().map(|entry| &entry.item),
Some(Item::ToolCall { call_id, .. }) if call_id == "call_1"
));
Ok(match self.0 {
InvalidIdentityMode::ContinuedCall => {
info.call.id = "different-call".to_string();
PreToolAction::Continue
}
InvalidIdentityMode::SyntheticResult => PreToolAction::SyntheticResult(
ToolResult::error("different-call", "invalid synthetic result"),
),
})
}
}
#[tokio::test]
async fn interceptor_cannot_change_tool_call_identity() {
for mode in [
InvalidIdentityMode::ContinuedCall,
InvalidIdentityMode::SyntheticResult,
] {
let client = MockLlmClient::new(vec![
Event::tool_use_start(0, "call_1", "echo"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("echo", 1).definition());
engine.set_interceptor(InvalidIdentityPolicy(mode));
let mut history = History::new();
let result = engine.run(&mut history, "identity").await;
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(failure),
)) = result.result
else {
panic!("invalid tool identity must interrupt with a typed failure");
};
assert_eq!(failure.phase(), InterceptorPhase::PreToolCall);
assert_eq!(
failure.error().category(),
InterceptorErrorCategory::ContractViolation
);
assert!(
!history
.items()
.any(|item| matches!(item, Item::ToolResult { .. }))
);
}
}
#[tokio::test]
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let client = MockLlmClient::new(vec![
@@ -1181,16 +1283,24 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let tool = SlowTool::new("confirmed", 1);
engine.register_tool(tool.definition());
struct AbortAfterResult;
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
struct AbortAfterResult {
lifecycle: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl Interceptor for AbortAfterResult {
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
PostToolAction::Abort("policy stopped the run".to_string())
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
self.lifecycle.lock().unwrap().push("post_tool_call");
Ok(PostToolAction::Abort("policy stopped the run".to_string()))
}
}
engine.set_interceptor(AbortAfterResult);
engine.set_interceptor(AbortAfterResult {
lifecycle: observed.clone(),
});
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone();
@@ -1210,7 +1320,7 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
assert_eq!(tool.call_count(), 1);
assert_eq!(
observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"]
["committed", "published", "post_tool_call", "run-returned"]
);
assert!(matches!(
output.result,
@@ -1239,3 +1349,93 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
} if call_id == "call_confirmed"
)));
}
#[derive(Clone, Copy)]
enum PostToolStopMode {
Abort,
Failure,
}
struct StopFirstParallelResult(PostToolStopMode);
#[async_trait]
impl Interceptor for StopFirstParallelResult {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
if info.call.id != "call_fast" {
return Ok(PostToolAction::Continue);
}
tokio::time::sleep(Duration::from_millis(5)).await;
match self.0 {
PostToolStopMode::Abort => Ok(PostToolAction::Abort("stop parallel batch".to_string())),
PostToolStopMode::Failure => Err(InterceptorError::new(
InterceptorErrorCategory::Policy,
"reject parallel batch",
)),
}
}
}
#[tokio::test]
async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning() {
for mode in [PostToolStopMode::Abort, PostToolStopMode::Failure] {
let first_response = vec![
Event::tool_use_start(0, "call_fast", "fast"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_ready", "ready"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let second_response = vec![
Event::text_block_start(0),
Event::text_delta(0, "next run completed"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![first_response, second_response]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("fast", 0).definition());
engine.register_tool(SlowTool::new("ready", 1).definition());
engine.set_interceptor(StopFirstParallelResult(mode));
let mut history = History::new();
let output = engine.run(&mut history, "parallel stop").await;
match mode {
PostToolStopMode::Abort => assert!(matches!(
output.result,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Aborted(ref reason)
)) if reason == "stop parallel batch"
)),
PostToolStopMode::Failure => assert!(matches!(
output.result,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(ref failure)
)) if failure.phase() == InterceptorPhase::PostToolCall
)),
}
let terminal_ids: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(terminal_ids.len(), 2);
assert!(terminal_ids.contains(&"call_fast"));
assert!(terminal_ids.contains(&"call_ready"));
let mut engine = output.engine;
let next = engine.run(&mut history, "next run").await;
assert!(matches!(next, EngineRunExit::Finished));
}
}
+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::header::AUTHORIZATION;
pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
RuntimeSummary as BackendRuntimeSummary,
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
WorkerImplementationSummary as BackendWorkerImplementationSummary,
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
WorkerOperationState as BackendWorkerOperationState,
WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
@@ -171,6 +177,47 @@ struct UploadedFileResponse {
file: protocol::UploadedFileRef,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunchTarget {
pub base_url: String,
pub workspace_id: Option<String>,
}
impl BackendWorkerLaunchTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<String>) -> Self {
Self {
base_url: base_url.into(),
workspace_id,
}
}
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
self.workspace_id = Some(workspace_id.into());
}
pub fn workspace_id(&self) -> Option<&str> {
self.workspace_id.as_deref()
}
pub fn runtime_target(
&self,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
BackendRuntimeClientError::InvalidTarget(
"workspace_id is required before creating a Backend worker".to_string(),
)
})?;
Ok(BackendRuntimeTarget::new(
self.base_url.clone(),
workspace_id,
runtime_id,
worker_id,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRuntimeListTarget {
pub base_url: String,
@@ -255,6 +302,58 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
}
}
pub async fn get_backend_worker_launch_options(
target: &BackendWorkerLaunchTarget,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
validate_launch_target(target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
get_backend_worker_launch_options_with_client(target, &api).await
}
async fn get_backend_worker_launch_options_with_client(
target: &BackendWorkerLaunchTarget,
api: &BackendApiClient,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
let path = backend_workspace_workers_launch_options_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
);
let response = api.request(HttpMethod::GET, &path)?.send().await?;
let response = api.require_success(response).await?;
Ok(response.json::<BackendWorkerLaunchOptions>().await?)
}
pub async fn create_backend_worker(
target: &BackendWorkerLaunchTarget,
request: &BackendCreateWorkerRequest,
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
validate_launch_target(target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
create_backend_worker_with_client(target, request, &api).await
}
async fn create_backend_worker_with_client(
target: &BackendWorkerLaunchTarget,
request: &BackendCreateWorkerRequest,
api: &BackendApiClient,
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
let path = backend_workspace_workers_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
);
let response = api
.request(HttpMethod::POST, &path)?
.json(request)
.send()
.await?;
let response = api.require_success(response).await?;
Ok(response.json::<BackendCreateWorkerResponse>().await?)
}
pub async fn list_backend_workers(
target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
@@ -462,6 +561,30 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
Ok(())
}
fn validate_launch_target(
target: &BackendWorkerLaunchTarget,
) -> Result<(), BackendRuntimeClientError> {
if target.base_url.trim().is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL is required".to_string(),
));
}
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
match target.workspace_id.as_deref() {
Some("") => Err(BackendRuntimeClientError::InvalidTarget(
"workspace_id must not be empty".to_string(),
)),
None => Err(BackendRuntimeClientError::InvalidTarget(
"workspace selection is required before creating a Backend worker".to_string(),
)),
Some(_) => Ok(()),
}
}
fn validate_list_target(
target: &BackendRuntimeListTarget,
) -> Result<(), BackendRuntimeClientError> {
@@ -496,6 +619,17 @@ fn validate_list_target(
Ok(())
}
fn backend_workspace_workers_path(workspace_id: &str) -> String {
format!("/api/w/{}/workers", path_segment_encode(workspace_id))
}
fn backend_workspace_workers_launch_options_path(workspace_id: &str) -> String {
format!(
"{}/launch-options",
backend_workspace_workers_path(workspace_id)
)
}
fn backend_runtimes_path(workspace_id: &str) -> String {
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
}
@@ -580,6 +714,155 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve_json_once(body: serde_json::Value) -> (String, tokio::task::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let header_end = loop {
let mut buffer = [0_u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "client closed before sending HTTP headers");
request.extend_from_slice(&buffer[..read]);
if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") {
break position + 4;
}
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
while request.len() < header_end + content_length {
let mut buffer = [0_u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "client closed before sending HTTP body");
request.extend_from_slice(&buffer[..read]);
}
let body = serde_json::to_vec(&body).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
String::from_utf8(request).unwrap()
});
(base_url, task)
}
#[tokio::test]
async fn launch_options_request_uses_workspace_path_and_bearer_auth() {
let (base_url, server) = serve_json_once(serde_json::json!({
"workspace_id": "team main",
"runtimes": [{
"runtime_id": "embedded",
"display_name": "Embedded",
"built_in": true,
"worker_creation_available": true,
"working_directory_required": false,
"status": "online",
"diagnostics": []
}],
"default_profile": "builtin:default",
"profiles": [{
"id": "builtin:default",
"label": "Default",
"description": ""
}],
"repositories": [],
"working_directories": [],
"diagnostics": []
}))
.await;
let target = BackendWorkerLaunchTarget::new(&base_url, Some("team main".to_string()));
let api = BackendApiClient::from_access_token_for_test(&base_url, "launch-secret").unwrap();
let response = get_backend_worker_launch_options_with_client(&target, &api)
.await
.unwrap();
assert_eq!(response.runtimes[0].runtime_id, "embedded");
let request = server.await.unwrap();
assert!(request.starts_with("GET /api/w/team%20main/workers/launch-options HTTP/1.1\r\n"));
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer launch-secret\r\n")
);
}
#[tokio::test]
async fn create_worker_posts_frontend_contract_to_workspace_path() {
let (base_url, server) = serve_json_once(serde_json::json!({
"workspace_id": "workspace-1",
"runtime_id": "embedded",
"worker_id": "worker-1",
"console_href": "/w/workspace-1/workers/embedded/worker-1",
"worker": {
"runtime_id": "embedded",
"worker_id": "worker-1",
"host_id": "host-1",
"display_name": "Coder one",
"label": "Coder one",
"profile": "builtin:coder",
"singleton_key": null,
"tags": [],
"workspace": {
"visibility": "workspace",
"identity": "workspace",
"workspace_id": "workspace-1"
},
"state": "idle",
"last_seen_at": null,
"pinned": false,
"retention_state": "resident",
"implementation": {"kind": "embedded", "display_hint": "Embedded"},
"capabilities": {"can_stop": true, "can_spawn_followup": false},
"diagnostics": []
},
"diagnostics": []
}))
.await;
let target = BackendWorkerLaunchTarget::new(&base_url, Some("workspace-1".to_string()));
let api = BackendApiClient::from_access_token_for_test(&base_url, "create-secret").unwrap();
let create = BackendCreateWorkerRequest {
runtime_id: "embedded".to_string(),
display_name: "Coder one".to_string(),
profile: Some("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory: None,
control_operation_id: None,
};
let response = create_backend_worker_with_client(&target, &create, &api)
.await
.unwrap();
assert_eq!(response.worker_id, "worker-1");
let request = server.await.unwrap();
assert!(request.starts_with("POST /api/w/workspace-1/workers HTTP/1.1\r\n"));
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer create-secret\r\n")
);
let body = request.split_once("\r\n\r\n").unwrap().1;
let body: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(body["runtime_id"], "embedded");
assert_eq!(body["display_name"], "Coder one");
assert_eq!(body["profile"], "builtin:coder");
assert_eq!(body["initial_submit"], serde_json::json!([]));
assert_eq!(body["working_directory"], serde_json::Value::Null);
}
#[test]
fn protocol_url_uses_backend_runtime_worker_identity() {
@@ -631,7 +914,7 @@ mod tests {
"working_directory": {
"working_directory_id": "wd-1",
"repository_key": "main",
"materializer_kind": "local_git_worktree",
"materializer_kind": "runtime_git_clone",
"status": "active",
"occupied_by": {
"runtime_id": "arcadia",
+47 -20
View File
@@ -1,9 +1,9 @@
use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::fmt;
use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
WorkspaceSummary,
};
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
pub type BackendWorkspace = WorkspaceSummary;
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRequest {
pub operation_key: String,
pub display_name: String,
pub repository: CreateBackendWorkspaceRepository,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRepository {
pub uri: String,
pub display_name: Option<String>,
pub default_ref: Option<String>,
}
pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget {
@@ -73,6 +58,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
}
}
pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
}
pub fn list_backend_workspace_repositories_blocking(
target: &BackendWorkspaceCatalogTarget,
workspace_id: &str,
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
if workspace_id.is_empty()
|| workspace_id.len() > 200
|| !workspace_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(BackendWorkspaceClientError::InvalidTarget(
"Workspace id returned by Backend is invalid".to_string(),
));
}
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
.send()?;
client.check_status(response.status())?;
let response = response.json::<RepositoryListResponse>()?;
if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(),
));
}
Ok(response.items)
}
pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
@@ -149,8 +176,8 @@ mod tests {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()),
},
};
+8 -7
View File
@@ -112,26 +112,27 @@ mod tests {
async fn encodes_methods_and_decodes_events_above_transport() {
let mut socket = TestSocket::default();
socket.incoming.push_back(
encode_event(&Event::Status {
status: WorkerStatus::Idle,
encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.expect("encode event"),
);
let mut client = Client::new(socket);
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
assert!(matches!(
decode_method(&client.socket.sent[0]),
Ok(Method::Run { .. })
Ok(Method::Submit { .. })
));
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
Ok(Some(Event::WorkerState { .. }))
));
}
}
+16 -10
View File
@@ -21,23 +21,29 @@ pub use backend_auth::{
poll_device_login, start_device_login, wait_for_device_login,
};
pub use backend_runtime::{
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError,
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic,
BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
};
pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
CreateBackendWorkspaceResponse, create_backend_workspace,
list_backend_workspace_repositories_blocking, list_backend_workspaces,
list_backend_workspaces_blocking,
};
pub use client::{Client, ClientError};
pub use target::{
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent,
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
};
pub use workspace_api::{
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
+22 -1
View File
@@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf};
use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget,
BackendRuntimeTarget, BackendWorkerLaunchTarget,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -123,6 +123,11 @@ pub struct Dashboard {
pub workspace_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunch {
pub target: BackendWorkerLaunchTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList {
pub backend_target: BackendRuntimeListTarget,
@@ -199,6 +204,13 @@ pub trait Target: fmt::Debug + Send + Sync {
Err(TargetError::unsupported("Worker dashboard", self.kind()))
}
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Err(TargetError::unsupported(
"Backend Worker launch",
self.kind(),
))
}
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported("Worker listing", self.kind()))
}
@@ -299,6 +311,15 @@ impl Target for BackendTarget {
})
}
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Ok(BackendWorkerLaunch {
target: BackendWorkerLaunchTarget::new(
self.base_url.clone(),
self.workspace_id.clone(),
),
})
}
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList {
backend_target: BackendRuntimeListTarget::new(
+8 -7
View File
@@ -89,17 +89,20 @@ mod tests {
let mut client = Client::new(socket);
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
assert!(matches!(
peer.next().await.as_deref().map(decode_method),
Some(Ok(Method::Run { .. }))
Some(Ok(Method::Submit { .. }))
));
peer.send(
encode_event(&Event::Status {
status: WorkerStatus::Idle,
encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.expect("encode event"),
)
@@ -107,9 +110,7 @@ mod tests {
.expect("send event");
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
Ok(Some(Event::WorkerState { .. }))
));
}
}
+11 -10
View File
@@ -113,8 +113,8 @@ mod tests {
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let event = encode_event(&Event::Status {
status: WorkerStatus::Idle,
let event = encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.unwrap();
stream.write_all(event.as_bytes()).await.unwrap();
@@ -126,12 +126,7 @@ mod tests {
.await
.expect("client should receive event while alive")
.expect("transport should succeed");
assert!(matches!(
event,
Some(Event::Status {
status: WorkerStatus::Idle
})
));
assert!(matches!(event, Some(Event::WorkerState { .. })));
server.await.unwrap();
}
@@ -147,12 +142,18 @@ mod tests {
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
let received = server.await.unwrap().expect("method message");
assert!(matches!(decode_method(&received), Ok(Method::Run { .. })));
assert!(matches!(
decode_method(&received),
Ok(Method::Submit { .. })
));
}
#[tokio::test]
+8 -7
View File
@@ -114,10 +114,10 @@ mod tests {
assert!(matches!(
message,
Message::Text(ref text)
if matches!(decode_method(text), Ok(Method::Run { .. }))
if matches!(decode_method(text), Ok(Method::Submit { .. }))
));
let event = encode_event(&Event::Status {
status: WorkerStatus::Idle,
let event = encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.unwrap();
socket.send(Message::Text(event.into())).await.unwrap();
@@ -126,14 +126,15 @@ mod tests {
let request = format!("ws://{address}").into_client_request().unwrap();
let mut client = Client::new(Socket::connect(request).await.unwrap());
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
Ok(Some(Event::WorkerState { .. }))
));
server.await.unwrap();
}
+39 -2
View File
@@ -12,8 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
};
use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -241,6 +243,41 @@ impl BackendWorkspaceProductClient {
)
}
pub fn list_runtimes(
&self,
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
self.get_json("/runtimes")
}
pub fn runtime_detail(
&self,
runtime_id: &str,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
}
pub fn reveal_runtime_trust_key(
&self,
runtime_id: &str,
) -> Result<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
self.get_json(&format!(
"/runtimes/{}/trust-key",
encode_path_segment(runtime_id)
))
}
pub fn revoke_runtime_trust_key(
&self,
runtime_id: &str,
request: &RevokeRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::DELETE,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
self.get_json("/memory")
}
+8 -1
View File
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()),
},
};
assert_eq!(request.clone(), request);
assert_eq!(request.operation_key, "workspace-create-1");
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["repository_key"], "main");
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
assert!(json.get("operation_id").is_none());
assert!(json["repository"].get("display_name").is_none());
assert!(json["repository"].get("source").is_none());
}
#[test]
+12 -14
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use globset::Glob;
use ignore::WalkBuilder;
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink};
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
/// Execute a bounded glob entirely inside the provider process.
pub fn run_glob(
@@ -15,26 +15,24 @@ pub fn run_glob(
if !root.is_absolute() {
return Err(FsError::RelativePath(root.to_path_buf()));
}
if !access.is_readable(base) {
let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
path: PathBuf::from(request.path.as_str()),
source: error,
})?;
if !access.is_readable_paths(base, &base_resolved) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
}
if let Some(info) = direct_symlink(base)
&& info.target_exists
&& info.resolved_path.is_dir()
{
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Glob",
path: PathBuf::from(request.path.as_str()),
target: PathBuf::from("<provider-internal target>"),
});
}
let matcher = Glob::new(&request.pattern)
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
.compile_matcher();
let mut matches = Vec::new();
for entry in WalkBuilder::new(base).hidden(false).build().flatten() {
let mut walker = WalkBuilder::new(base);
walker.hidden(false).follow_links(false);
for entry in walker.build().flatten() {
let path = entry.path();
if !path.is_file() || !access.is_readable(path) {
let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !path.is_file() || !readable {
continue;
}
let relative = path.strip_prefix(base).unwrap_or(path);
+40 -9
View File
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use thiserror::Error;
pub use glob::run_glob;
pub use local::{run_edit, run_list, run_read, run_stat, run_write};
pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
pub use operation::*;
pub use search::run_grep;
@@ -22,6 +22,19 @@ pub use search::run_grep;
pub trait FsAccessPolicy: Send + Sync {
fn is_readable(&self, path: &Path) -> bool;
fn is_writable(&self, path: &Path) -> bool;
/// Authorize both the Workdir-visible path and its provider-resolved
/// target. Implementations that do not distinguish symbolic-link identity
/// retain resolved-target semantics through the defaults.
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_readable(resolved)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_writable(resolved)
}
}
/// First symlink encountered while resolving a provider path.
@@ -477,13 +490,14 @@ mod tests {
#[cfg(unix)]
#[test]
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap();
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
@@ -501,18 +515,35 @@ mod tests {
assert_eq!(file_result.match_count, 1);
assert!(file_result.output.starts_with("file-link.rs\n"));
let directory_error = run_grep(
let directory_result = run_grep(
&root,
root.join("directory-link"),
request("directory-link"),
&readable,
)
.unwrap_err();
assert!(matches!(
directory_error,
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
if path == root.join("directory-link")
));
.unwrap();
assert_eq!(directory_result.match_count, 1);
assert!(
directory_result
.output
.starts_with("directory-link/nested.rs\n")
);
let glob_result = run_glob(
&root,
&root.join("directory-link"),
GlobRequest {
pattern: "**/*.rs".to_string(),
path: FsPath::new("directory-link").unwrap(),
limit: 10,
},
&readable,
)
.unwrap();
assert_eq!(
glob_result.paths,
vec![FsPath::new("directory-link/nested.rs").unwrap()]
);
let broken_error = run_grep(
&root,
+72 -34
View File
@@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
@@ -18,7 +19,8 @@ pub fn run_stat(
) -> Result<StatResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
if !access.is_readable(&path) {
let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
if !access.is_readable_paths(&path, &resolved) {
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
}
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
@@ -45,7 +47,7 @@ pub fn run_read(
) -> Result<ReadResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?;
let path = require_access(&path, &logical, access, false, false)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -99,7 +101,7 @@ pub fn run_write(
let path = resolve(root, &logical)?;
let created = !path.exists();
if path.exists() {
let target = require_access(&path, &logical, access, true)?;
let target = require_access(&path, &logical, access, true, false)?;
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -113,12 +115,8 @@ pub fn run_write(
if request.expected_hash.is_some() {
return Err(FsError::Conflict(logical.as_str().to_string()));
}
let parent = path.parent().ok_or_else(|| {
FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
})?;
let parent_logical = logical_parent(&logical);
require_access(parent, &parent_logical, access, true)?;
atomic_write(&path, &request.content, &logical)?;
let target = require_access(&path, &logical, access, true, true)?;
atomic_write(&target, &request.content, &logical)?;
}
Ok(WriteResult {
bytes_written: request.content.len(),
@@ -133,7 +131,7 @@ pub fn run_edit(
) -> Result<EditResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
let target = require_access(&path, &logical, access, true)?;
let target = require_access(&path, &logical, access, true, false)?;
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
let actual_hash = hash_bytes(&bytes);
if actual_hash != request.expected_hash {
@@ -173,7 +171,8 @@ pub fn run_list(
) -> Result<ListResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?;
let logical_base = path.clone();
let path = require_access(&path, &logical, access, false, true)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
@@ -183,7 +182,15 @@ pub fn run_list(
for entry in read_dir {
let entry = entry.map_err(|error| map_io(&logical, error))?;
let absolute = entry.path();
if !access.is_readable(&absolute) {
let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
})?;
let logical_absolute = logical_base.join(relative_to_base);
let resolved = match resolve_access_path(&absolute) {
Ok(resolved) => resolved,
Err(_) => continue,
};
if !access.is_readable_paths(&logical_absolute, &resolved) {
continue;
}
let link_metadata =
@@ -203,7 +210,7 @@ pub fn run_list(
} else {
EntryKind::Other
};
let relative = absolute.strip_prefix(root).map_err(|_| {
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?;
entries.push(ListEntry {
@@ -247,19 +254,24 @@ fn require_access(
logical: &FsPath,
access: &dyn FsAccessPolicy,
write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> {
if let Some(info) = direct_symlink(path) {
if !info.target_exists {
return Err(FsError::BrokenSymlink {
path: PathBuf::from(logical.as_str()),
link: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"),
});
}
let symlink = direct_symlink(path);
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(FsError::BrokenSymlink {
path: PathBuf::from(logical.as_str()),
link: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"),
});
}
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
if let Some(info) = symlink {
let allowed = if write {
access.is_writable(&info.resolved_path)
access.is_writable_paths(path, &resolved)
} else {
access.is_readable(&info.resolved_path)
access.is_readable_paths(path, &resolved)
};
if !allowed {
return Err(FsError::SymlinkOutOfScope {
@@ -268,21 +280,21 @@ fn require_access(
required_permission: if write { "write" } else { "read" },
});
}
if write && info.resolved_path.is_dir() {
if !allow_symlink_directory && info.resolved_path.is_dir() {
return Err(FsError::SymlinkTargetIsDirectory {
path: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"),
});
}
return Ok(info.resolved_path);
return Ok(resolved);
}
let allowed = if write {
access.is_writable(path)
access.is_writable_paths(path, &resolved)
} else {
access.is_readable(path)
access.is_readable_paths(path, &resolved)
};
if allowed {
Ok(path.to_path_buf())
Ok(resolved)
} else if write {
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
} else {
@@ -290,12 +302,38 @@ fn require_access(
}
}
fn logical_parent(path: &FsPath) -> FsPath {
let parent = Path::new(path.as_str())
.parent()
.unwrap_or_else(|| Path::new(""))
.to_string_lossy();
FsPath::new(parent).unwrap_or_else(|_| FsPath::root())
/// Resolve every existing component of an absolute provider path while
/// retaining a missing final tail for create operations. Dangling symlinks are
/// rejected because no resolved authority identity can be established.
pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
let mut cursor = path;
let mut missing = Vec::<OsString>::new();
loop {
match fs::canonicalize(cursor) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return Ok(resolved);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if fs::symlink_metadata(cursor)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(error);
}
let name = cursor.file_name().ok_or(error)?;
missing.push(name.to_os_string());
cursor = cursor.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"path has no existing ancestor",
)
})?;
}
Err(error) => return Err(error),
}
}
}
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
+23 -24
View File
@@ -10,7 +10,9 @@ use ignore::WalkBuilder;
use ignore::overrides::{Override, OverrideBuilder};
use ignore::types::{Types, TypesBuilder};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
use crate::{
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
};
struct ContentLine {
path: PathBuf,
@@ -220,14 +222,28 @@ pub fn run_grep(
return Err(FsError::RelativePath(base));
}
let symlink = direct_symlink(&base);
if !access.is_readable(&base) {
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.resolved_path.clone(),
});
}
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
if !access.is_readable_paths(&base, &resolved_base) {
return Err(if let Some(info) = symlink.as_ref() {
let link_parent_readable = info
.link_path
.parent()
.map(|parent| access.is_readable(parent))
.and_then(|parent| {
resolve_access_path(parent)
.ok()
.map(|resolved| access.is_readable_paths(parent, &resolved))
})
.unwrap_or(false);
if info.target_exists && link_parent_readable {
if link_parent_readable {
FsError::SymlinkOutOfScope {
path: base.clone(),
target: info.resolved_path.clone(),
@@ -240,15 +256,6 @@ pub fn run_grep(
FsError::OutOfScope(base.clone())
});
}
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.target_path.clone(),
});
}
}
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e),
@@ -259,16 +266,6 @@ pub fn run_grep(
base.display()
)));
}
if base_meta.is_dir()
&& let Some(info) = symlink.as_ref()
{
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Grep",
path: base.clone(),
target: info.resolved_path.clone(),
});
}
let filter_base = if base_meta.is_file() { root } else { &base };
let types = build_types(p.file_type.as_deref())?;
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
@@ -331,7 +328,9 @@ pub fn run_grep(
continue;
}
let path = entry.path();
if !access.is_readable(path) {
let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !readable {
continue;
}
if scan_path(
+187 -101
View File
@@ -15,13 +15,13 @@ use serde::{Deserialize, Serialize};
use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
WorkerManifest, WorkerMeta,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
};
/// Partial-form Worker manifest. Every field is optional; one or more
@@ -54,10 +54,6 @@ pub struct WorkerManifestConfig {
/// disabled after cascade merge.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit plugin package enablement entries. Discovery/resolution is a
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
/// Explicit Model Context Protocol provider declarations. Config parsing
/// never starts a local MCP subprocess.
#[serde(default)]
@@ -67,15 +63,13 @@ pub struct WorkerManifestConfig {
/// First-class web tool opt-in. See [`WebConfig`].
#[serde(default)]
pub web: Option<WebConfig>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills directories. See [`crate::SkillsConfig`].
#[serde(default)]
pub skills: Option<SkillsConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfigPartial {
#[serde(default)]
pub task: Option<FeatureFlagConfigPartial>,
@@ -103,8 +97,6 @@ pub struct FeatureConfigPartial {
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
#[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
}
impl FeatureConfigPartial {
@@ -147,7 +139,6 @@ impl FeatureConfigPartial {
other.orchestration,
FeatureFlagConfigPartial::merge,
),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
}
}
}
@@ -193,18 +184,86 @@ impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub staging: Option<bool>,
pub staging_tools: Option<bool>,
#[serde(default)]
pub resident: Option<MemoryResidentProfileConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
#[serde(default)]
pub consolidation: Option<MemoryConsolidationProfileConfigPartial>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryResidentProfileConfigPartial {
#[serde(default)]
pub inject_summary: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryExtractionProfileConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub model: Option<ModelManifest>,
#[serde(default)]
pub threshold: Option<u64>,
#[serde(default)]
pub worker_max_turns: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidationProfileConfigPartial {
#[serde(default)]
pub request_enabled: Option<bool>,
}
impl MemoryFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
staging: other.staging.or(self.staging),
staging_tools: other.staging_tools.or(self.staging_tools),
resident: merge_option(
self.resident,
other.resident,
MemoryResidentProfileConfigPartial::merge,
),
extraction: merge_option(
self.extraction,
other.extraction,
MemoryExtractionProfileConfigPartial::merge,
),
consolidation: merge_option(
self.consolidation,
other.consolidation,
MemoryConsolidationProfileConfigPartial::merge,
),
}
}
}
impl MemoryResidentProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
inject_summary: other.inject_summary.or(self.inject_summary),
}
}
}
impl MemoryExtractionProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
model: other.model.or(self.model),
threshold: other.threshold.or(self.threshold),
worker_max_turns: other.worker_max_turns.or(self.worker_max_turns),
}
}
}
@@ -253,13 +312,21 @@ impl MergeRequestFeatureConfigPartial {
}
}
impl MemoryConsolidationProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
request_enabled: other.request_enabled.or(self.request_enabled),
}
}
}
impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self {
Self {
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
memory: value
.memory
.map(MemoryFeatureConfig::from)
.map(ResolvedMemoryFeatureConfig::from)
.unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
@@ -296,10 +363,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.orchestration
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
}
}
}
@@ -329,20 +392,52 @@ impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
}
}
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
impl From<MemoryFeatureConfigPartial> for ResolvedMemoryFeatureConfig {
fn from(value: MemoryFeatureConfigPartial) -> Self {
let resident = value.resident.unwrap_or_default();
let extraction = value.extraction.unwrap_or_default();
let consolidation = value.consolidation.unwrap_or_default();
Self {
enabled: value.enabled.unwrap_or_default(),
staging: value.staging.unwrap_or_default(),
profile: MemoryFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
staging_tools: value.staging_tools.unwrap_or_default(),
resident: MemoryResidentProfileConfig {
inject_summary: resident.inject_summary.unwrap_or(true),
},
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
threshold: extraction.threshold.or(Some(50_000)),
worker_max_turns: extraction
.worker_max_turns
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
},
consolidation: MemoryConsolidationProfileConfig {
request_enabled: consolidation.request_enabled.unwrap_or(true),
},
},
workspace_settings: None,
}
}
}
impl From<MemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: MemoryFeatureConfig) -> Self {
impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: ResolvedMemoryFeatureConfig) -> Self {
Self {
enabled: Some(value.enabled),
staging: Some(value.staging),
enabled: Some(value.profile.enabled),
staging_tools: Some(value.profile.staging_tools),
resident: Some(MemoryResidentProfileConfigPartial {
inject_summary: Some(value.profile.resident.inject_summary),
}),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
threshold: value.profile.extraction.threshold,
worker_max_turns: value.profile.extraction.worker_max_turns,
}),
consolidation: Some(MemoryConsolidationProfileConfigPartial {
request_enabled: Some(value.profile.consolidation.request_enabled),
}),
}
}
}
@@ -411,7 +506,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()),
}
}
}
@@ -543,13 +637,23 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
(removed; use compaction.prune_protected_tokens)",
));
}
if value.get("memory").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: memory (removed; configure feature.memory)",
));
}
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value
.get("memory")
.get("feature")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
.is_some_and(|table| table.contains_key("plugins"))
{
return Err(toml::de::Error::custom(
"unknown field in manifest: memory.extract_worker_max_input_tokens (removed)",
"unknown field in manifest: feature.plugins (dynamic Plugins are not supported)",
));
}
if value
@@ -633,11 +737,6 @@ impl WorkerManifestConfig {
for rule in &mut self.delegation_scope.deny {
rule.target = join_if_relative(base, &rule.target);
}
if let Some(ref mut memory) = self.memory
&& let Some(ref mut root) = memory.workspace_root
{
*root = join_if_relative(base, root);
}
if let Some(ref mut compaction) = self.compaction
&& let Some(ref mut cp) = compaction.model
{
@@ -674,7 +773,6 @@ impl WorkerManifestConfig {
PermissionConfigPartial::merge,
),
feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
mcp: merge_mcp_config(self.mcp, upper.mcp),
compaction: merge_option(
self.compaction,
@@ -682,7 +780,6 @@ impl WorkerManifestConfig {
CompactionConfigPartial::merge,
),
web: merge_option(self.web, upper.web, WebConfig::merge),
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
}
}
@@ -695,16 +792,6 @@ impl SkillsConfig {
}
}
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
let upper_has_resolved_plan = upper.has_resolved_plan();
base.enabled.extend(upper.enabled);
if upper_has_resolved_plan {
base.resolved = upper.resolved;
base.diagnostics = upper.diagnostics;
}
base
}
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
base.stdio_servers.extend(upper.stdio_servers);
base
@@ -754,32 +841,6 @@ impl crate::WebFetchConfig {
}
}
impl MemoryConfig {
fn merge(self, upper: Self) -> Self {
Self {
workspace_root: upper.workspace_root.or(self.workspace_root),
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
inject_summary: upper.inject_summary.or(self.inject_summary),
workspace_id: upper.workspace_id.or(self.workspace_id),
settings_revision: upper.settings_revision.or(self.settings_revision),
language: upper.language.or(self.language),
extract_model: upper.extract_model.or(self.extract_model),
extract_threshold: upper.extract_threshold.or(self.extract_threshold),
extract_worker_max_turns: upper
.extract_worker_max_turns
.or(self.extract_worker_max_turns),
consolidation_model: upper.consolidation_model.or(self.consolidation_model),
consolidation_threshold_files: upper
.consolidation_threshold_files
.or(self.consolidation_threshold_files),
consolidation_threshold_bytes: upper
.consolidation_threshold_bytes
.or(self.consolidation_threshold_bytes),
}
}
}
impl WorkerMetaConfig {
fn merge(self, upper: Self) -> Self {
Self {
@@ -1219,11 +1280,9 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
session,
permissions,
feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
mcp: cfg.mcp,
compaction,
web: cfg.web,
memory: cfg.memory,
skills: cfg.skills,
profile: None,
})
@@ -1260,18 +1319,17 @@ mod tests {
target: abs("/worker"),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
},
delegation_scope: ScopeConfig::default(),
permissions: None,
feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
mcp: McpConfig::default(),
session: None,
compaction: None,
web: None,
memory: None,
skills: None,
}
}
@@ -1507,6 +1565,7 @@ mod tests {
target: PathBuf::from("secrets"),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
});
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
@@ -1644,6 +1703,7 @@ mod tests {
target: abs("/a"),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
},
@@ -1655,11 +1715,13 @@ mod tests {
target: abs("/b"),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
deny: vec![ScopeRule {
target: abs("/a/secret"),
permission: Permission::Read,
recursive: false,
symlink_policy: Default::default(),
}],
},
..Default::default()
@@ -1846,29 +1908,50 @@ prune_protected_turns = 3
}
#[test]
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() {
let bad = r#"
[memory]
extract_worker_max_input_tokens = 30000
"#;
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
assert!(
err.to_string()
.contains("memory.extract_worker_max_input_tokens"),
"unexpected error: {err}"
);
fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
let cfg = WorkerManifestConfig::from_toml(
r#"
[feature.memory]
enabled = true
staging_tools = false
[feature.memory.resident]
inject_summary = false
[feature.memory.extraction]
enabled = true
threshold = 42000
worker_max_turns = 2
[feature.memory.consolidation]
request_enabled = false
"#,
)
.unwrap();
let memory = cfg.feature.memory.unwrap();
assert_eq!(memory.enabled, Some(true));
assert_eq!(memory.staging_tools, Some(false));
assert_eq!(memory.resident.unwrap().inject_summary, Some(false));
assert_eq!(memory.consolidation.unwrap().request_enabled, Some(false));
let extraction = memory.extraction.unwrap();
assert_eq!(extraction.enabled, Some(true));
assert_eq!(extraction.threshold, Some(42_000));
assert_eq!(extraction.worker_max_turns, Some(2));
}
#[test]
fn from_toml_accepts_extract_worker_max_turns() {
let cfg = WorkerManifestConfig::from_toml(
fn from_toml_rejects_legacy_top_level_memory_authority() {
let err = WorkerManifestConfig::from_toml(
r#"
[memory]
extract_worker_max_turns = 2
"#,
)
.unwrap();
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2));
.unwrap_err();
assert!(
err.to_string().contains("memory"),
"unexpected error: {err}"
);
}
#[test]
@@ -1948,7 +2031,7 @@ worker_max_turns = 7
fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled);
@@ -2002,6 +2085,7 @@ enabled = false
target: abs("/worker"),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
},
@@ -2025,8 +2109,8 @@ enabled = false
}
);
assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.staging);
assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.memory.profile.staging_tools);
assert!(!manifest.feature.objective.enabled);
}
@@ -2074,7 +2158,7 @@ readiness_check = true
enabled = true
[feature.memory]
staging = true
staging_tools = true
[feature.manage_workdir]
enabled = true
@@ -2104,6 +2188,7 @@ enabled = true
target: abs("/worker"),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
},
@@ -2111,8 +2196,8 @@ enabled = true
})
.try_into()
.unwrap();
assert!(manifest.feature.memory.enabled);
assert!(manifest.feature.memory.staging);
assert!(manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.profile.staging_tools);
assert!(manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket.authoring);
@@ -2180,6 +2265,7 @@ permission = "write"
target: abs("/worker"),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
},
+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;
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
/// See [`crate::MemoryConfig::extract_worker_max_turns`].
/// See [`crate::MemoryExtractionProfileConfig::worker_max_turns`].
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
+666 -177
View File
@@ -29,7 +29,7 @@ pub use profile::{
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
validate_profile_execution_target,
};
pub use protocol::{Permission, ScopeRule};
pub use protocol::{Permission, ScopeRule, SymlinkPolicy};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
use std::collections::{BTreeMap, HashMap};
@@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize};
/// part of the manifest — it is the process's `std::env::current_dir()`
/// at construction time.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerManifest {
pub worker: WorkerMeta,
pub model: ModelManifest,
@@ -68,10 +69,6 @@ pub struct WorkerManifest {
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
#[serde(default)]
pub feature: FeatureConfig,
/// Explicit plugin package enablement. Discovery remains read-only; only
/// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)]
pub plugins: plugin::PluginConfig,
/// Explicit external Model Context Protocol provider configuration. This
/// is config data only: declaring a server never starts a subprocess or
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
@@ -80,11 +77,6 @@ pub struct WorkerManifest {
pub mcp: McpConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem configuration. Presence of `[memory]` configures memory
/// storage, extraction, consolidation, and resident injection, but memory
/// tools are surfaced only when `[feature.memory].enabled = true`.
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// First-class web tools configuration. Network access remains fail-closed
/// under this config; WebSearch/WebFetch schemas are surfaced only when
/// `[feature.web].enabled = true`.
@@ -109,12 +101,13 @@ pub struct WorkerManifest {
/// profile/config data only: they do not carry runtime Worker names, sockets,
/// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfig {
#[serde(default)]
pub task: FeatureFlagConfig,
#[serde(default)]
pub memory: MemoryFeatureConfig,
pub memory: ResolvedMemoryFeatureConfig,
#[serde(default)]
pub web: FeatureFlagConfig,
#[serde(default)]
@@ -139,15 +132,13 @@ pub struct FeatureConfig {
pub merge_request: MergeRequestFeatureConfig,
#[serde(default)]
pub orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
}
impl Default for FeatureConfig {
fn default() -> Self {
Self {
task: FeatureFlagConfig::disabled(),
memory: MemoryFeatureConfig::disabled(),
memory: ResolvedMemoryFeatureConfig::default(),
web: FeatureFlagConfig::disabled(),
image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(),
@@ -159,7 +150,6 @@ impl Default for FeatureConfig {
ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
}
}
}
@@ -222,34 +212,139 @@ const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryFeatureConfig {
#[serde(default)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryFeatureProfileConfig {
pub enabled: bool,
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
#[serde(default)]
pub staging: bool,
pub staging_tools: bool,
pub resident: MemoryResidentProfileConfig,
pub extraction: MemoryExtractionProfileConfig,
pub consolidation: MemoryConsolidationProfileConfig,
}
impl MemoryFeatureConfig {
pub const fn disabled() -> Self {
Self {
enabled: false,
staging: false,
}
impl MemoryFeatureProfileConfig {
pub fn disabled() -> Self {
Self::default()
}
pub const fn enabled() -> Self {
pub fn enabled() -> Self {
Self {
enabled: true,
staging: false,
..Self::default()
}
}
}
impl Default for MemoryFeatureConfig {
impl Default for MemoryFeatureProfileConfig {
fn default() -> Self {
Self::disabled()
Self {
enabled: false,
staging_tools: false,
resident: MemoryResidentProfileConfig::default(),
extraction: MemoryExtractionProfileConfig::default(),
consolidation: MemoryConsolidationProfileConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryResidentProfileConfig {
pub inject_summary: bool,
}
impl Default for MemoryResidentProfileConfig {
fn default() -> Self {
Self {
inject_summary: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryExtractionProfileConfig {
pub enabled: bool,
pub model: Option<ModelManifest>,
pub threshold: Option<u64>,
pub worker_max_turns: Option<u32>,
}
impl Default for MemoryExtractionProfileConfig {
fn default() -> Self {
Self {
enabled: true,
model: None,
threshold: Some(50_000),
worker_max_turns: defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryConsolidationProfileConfig {
pub request_enabled: bool,
}
impl Default for MemoryConsolidationProfileConfig {
fn default() -> Self {
Self {
request_enabled: true,
}
}
}
/// Immutable Memory execution configuration persisted in a resolved Worker Manifest.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default, deny_unknown_fields)]
pub struct ResolvedMemoryFeatureConfig {
pub profile: MemoryFeatureProfileConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
}
impl ResolvedMemoryFeatureConfig {
pub fn enabled(&self) -> bool {
self.profile.enabled
}
pub fn bind_workspace_settings(
&mut self,
settings: WorkspaceMemorySettingsSnapshot,
) -> Result<(), &'static str> {
if !self.profile.enabled {
if self.workspace_settings.is_some() {
return Err("disabled Memory feature must not carry Workspace settings");
}
return Ok(());
}
if self.workspace_settings.is_some() {
return Err("memory Workspace settings are already bound");
}
self.workspace_settings = Some(settings);
Ok(())
}
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
self.workspace_settings.clone()
}
pub fn validate_execution(&self) -> Result<(), &'static str> {
if self.profile.enabled && self.workspace_settings.is_none() {
return Err("enabled Memory feature requires trusted Workspace settings");
}
if !self.profile.enabled && self.workspace_settings.is_some() {
return Err("disabled Memory feature must not carry Workspace settings");
}
if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0
|| !is_normalized_workspace_memory_language(&settings.language))
{
return Err("Memory Workspace settings snapshot metadata is invalid");
}
Ok(())
}
}
@@ -484,98 +579,6 @@ pub struct WorkspaceMemorySettingsSnapshot {
pub language: String,
}
/// Memory subsystem configuration. Presence in the manifest enables
/// memory; `workspace_root` pins the memory workspace explicitly. When it
/// is absent, memory resolution searches upward from the Worker's pwd for a
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
/// as a memory root.
///
/// All fields are `Option`; defaults are applied at the consumer
/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple
/// (`upper.x.or(self.x)`) without a separate partial/resolved split.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryConfig {
/// Override for the memory workspace root. When `None`, consumers resolve
/// the root from their default path and ancestor `.yoi/memory` markers.
/// When set, must be an absolute path.
#[serde(default)]
pub workspace_root: Option<PathBuf>,
/// Maximum number of records returned by `MemoryQuery` /
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
#[serde(default)]
pub query_result_limit: Option<usize>,
/// Lines of context before and after each match in query excerpts.
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
#[serde(default)]
pub query_excerpt_lines: Option<usize>,
/// Whether the body of `memory/summary.md` is exposed in the resident
/// system-prompt section. `None` ⇒ enabled.
#[serde(default)]
pub inject_summary: Option<bool>,
/// Workspace that owns the bound Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
/// Monotonic revision of the bound Workspace Memory settings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_revision: Option<u64>,
/// Language from the bound Workspace Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Optional model for the extract worker. When `None`,
/// the main engine model is cloned via `clone_boxed()`. Lightweight
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
/// recommended.
#[serde(default)]
pub extract_model: Option<ModelManifest>,
/// Cumulative input-token threshold (since the last extract pointer)
/// that triggers an extract run. `None` disables the extract trigger
/// entirely; memory tools and resident injection still work, only
/// the auto-extract trigger is dormant.
#[serde(default)]
pub extract_threshold: Option<u64>,
/// Optional maximum extract-worker tool-loop depth. `None` leaves
/// the worker unlimited; the default bounds runaway short-context
/// loops. Falls through to
/// [`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`] when unset.
#[serde(default)]
pub extract_worker_max_turns: Option<u32>,
/// Optional model for the consolidation worker. When
/// `None`, the main engine model is cloned via `clone_boxed()`.
/// Reasoning-class models are recommended.
#[serde(default)]
pub consolidation_model: Option<ModelManifest>,
/// Consolidation trigger: file-count threshold of `_staging/`. The
/// consolidation run fires when the staging directory has at least
/// this many entries. Either threshold reaching its limit fires
/// consolidation (logical OR). `None` for both thresholds ⇒
/// consolidation disabled.
#[serde(default)]
pub consolidation_threshold_files: Option<usize>,
/// Consolidation trigger: byte-size threshold across all `_staging/`
/// entries. Either threshold reaching its limit fires consolidation.
/// `None` for both thresholds ⇒ consolidation disabled.
#[serde(default)]
pub consolidation_threshold_bytes: Option<u64>,
}
impl MemoryConfig {
/// Replace any untrusted manifest values with a trusted Workspace snapshot.
pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) {
self.workspace_id = Some(snapshot.workspace_id.clone());
self.settings_revision = Some(snapshot.settings_revision);
self.language = Some(snapshot.language.clone());
}
/// Return the complete bound Workspace settings snapshot, if every field is present.
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
Some(WorkspaceMemorySettingsSnapshot {
workspace_id: self.workspace_id.clone()?,
settings_revision: self.settings_revision?,
language: self.language.clone()?,
})
}
}
/// Worker metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerMeta {
@@ -931,6 +934,10 @@ impl Default for CompactionConfig {
}
impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
}
/// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?;
@@ -941,6 +948,267 @@ impl WorkerManifest {
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LegacyMemoryFeatureConfig {
enabled: bool,
staging: bool,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LegacyMemoryConfig {
#[serde(rename = "workspace_root")]
_workspace_root: Option<PathBuf>,
#[serde(rename = "query_result_limit")]
_query_result_limit: Option<usize>,
#[serde(rename = "query_excerpt_lines")]
_query_excerpt_lines: Option<usize>,
inject_summary: Option<bool>,
workspace_id: Option<String>,
settings_revision: Option<u64>,
language: Option<String>,
extract_model: Option<ModelManifest>,
extract_threshold: Option<u64>,
extract_worker_max_turns: Option<u32>,
consolidation_model: Option<ModelManifest>,
consolidation_threshold_files: Option<usize>,
consolidation_threshold_bytes: Option<u64>,
}
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
pub fn write_persisted_worker_manifest_snapshot(
manifest: &WorkerManifest,
) -> Result<serde_json::Value, serde_json::Error> {
Ok(serde_json::json!({
"schema_version": RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION,
"manifest": serde_json::to_value(manifest)?,
}))
}
/// Read a durable resolved Worker Manifest through the versioned compatibility
/// boundary. Runtime code must not deserialize persisted snapshots directly.
pub fn read_persisted_worker_manifest_snapshot(
snapshot: serde_json::Value,
) -> Result<WorkerManifest, serde_json::Error> {
let object = snapshot.as_object().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot must be an object",
))
})?;
if let Some(version) = object.get("schema_version") {
let version = version.as_u64().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot schema_version must be an integer",
))
})?;
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
{
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported resolved Worker manifest snapshot schema version {version}"),
)));
}
if object.len() != 2 {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot contains unknown fields",
)));
}
let mut manifest = object.get("manifest").cloned().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot is missing manifest",
))
})?;
if manifest
.as_object()
.is_some_and(|manifest| manifest.contains_key("memory"))
{
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"current resolved Worker manifest contains removed top-level memory authority",
)));
}
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_manifest_authority(&mut manifest)?;
}
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
}
migrate_legacy_resolved_manifest_snapshot(snapshot)
}
fn validate_persisted_worker_manifest(
manifest: WorkerManifest,
) -> Result<WorkerManifest, serde_json::Error> {
manifest
.feature
.memory
.validate_execution()
.map_err(|message| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
message,
))
})?;
Ok(manifest)
}
fn migrate_legacy_manifest_authority(
manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> {
let root = manifest.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest must be an object",
))
})?;
root.remove("plugins");
if let Some(feature) = root.get_mut("feature") {
let feature = feature.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest feature must be an object",
))
})?;
feature.remove("plugins");
feature.remove("ticket_orchestration");
if let Some(workers) = feature.remove("workers") {
feature
.entry("sub_worker".to_string())
.or_insert_with(|| workers.clone());
feature.entry("worker".to_string()).or_insert(workers);
}
if let Some(ticket) = feature
.get_mut("ticket")
.and_then(serde_json::Value::as_object_mut)
&& let Some(access) = ticket.remove("access")
&& ticket
.get("enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
&& access.as_str() == Some("lifecycle")
{
ticket.insert("authoring".to_string(), serde_json::Value::Bool(true));
ticket.insert("thread".to_string(), serde_json::Value::Bool(true));
ticket.insert("workflow".to_string(), serde_json::Value::Bool(true));
}
}
Ok(())
}
fn migrate_legacy_resolved_manifest_snapshot(
mut snapshot: serde_json::Value,
) -> Result<WorkerManifest, serde_json::Error> {
let root = snapshot.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest snapshot must be an object",
))
})?;
let legacy_memory = root.remove("memory");
let feature = root
.entry("feature")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest feature must be an object",
))
})?;
let legacy_feature_memory: LegacyMemoryFeatureConfig = serde_json::from_value(
feature
.remove("memory")
.unwrap_or_else(|| serde_json::json!({})),
)?;
let requested_enabled = legacy_feature_memory.enabled;
let staging_tools = legacy_feature_memory.staging;
let legacy_memory: LegacyMemoryConfig =
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
let mut workspace_settings = match (
legacy_memory.workspace_id,
legacy_memory.settings_revision,
legacy_memory.language,
) {
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
"workspace_id": workspace_id,
"settings_revision": settings_revision,
"language": language,
})),
(None, None, None) => None,
_ => {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest contains a partial Memory settings snapshot",
)));
}
};
if !requested_enabled {
workspace_settings = None;
}
// Legacy standalone manifests could enable process-local Memory without a
// Workspace-owned settings snapshot. That authority no longer exists, so
// migration safely disables Memory instead of treating the whole Worker
// snapshot as corrupt.
let enabled = requested_enabled && workspace_settings.is_some();
let extraction_enabled = legacy_memory.extract_threshold.is_some();
if legacy_memory.consolidation_model.is_some() {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest uses a Worker-owned consolidation model that cannot be migrated to Backend authority",
)));
}
let consolidation_enabled = match (
legacy_memory.consolidation_threshold_files,
legacy_memory.consolidation_threshold_bytes,
) {
(None, None) => false,
(Some(5), Some(50_000)) => true,
_ => {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest uses custom consolidation thresholds that cannot be migrated to Backend policy",
)));
}
};
let mut resolved = serde_json::json!({
"profile": {
"enabled": enabled,
"staging_tools": staging_tools,
"resident": {
"inject_summary": legacy_memory.inject_summary.unwrap_or(true),
},
"extraction": {
"enabled": extraction_enabled,
"model": serde_json::to_value(legacy_memory.extract_model)?,
"threshold": legacy_memory.extract_threshold,
"worker_max_turns": legacy_memory.extract_worker_max_turns,
},
"consolidation": {
"request_enabled": consolidation_enabled,
},
},
});
if let Some(workspace_settings) = workspace_settings {
resolved
.as_object_mut()
.expect("resolved Memory config is an object")
.insert("workspace_settings".to_string(), workspace_settings);
}
feature.insert("memory".to_string(), resolved);
migrate_legacy_manifest_authority(&mut snapshot)?;
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1101,33 +1369,61 @@ model_id = "claude-sonnet-4-20250514"
}
#[test]
fn parse_plugin_enablement_config() {
fn dynamic_plugin_manifest_config_is_rejected() {
let toml = format!(
"{MINIMAL_REQUIRED}\n\
[[plugins.enabled]]\n\
id = \"project:example\"\n\
version = \"0.1.0\"\n\
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
surfaces = [\"hook\"]\n\n\
[plugins.enabled.config]\n\
greeting = \"hello\"\n"
id = \"project:example\"\n"
);
let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.plugins.enabled.len(), 1);
let enabled = &manifest.plugins.enabled[0];
assert_eq!(enabled.id, "project:example");
assert_eq!(
enabled.version.as_ref().map(|version| version.0.as_str()),
Some("0.1.0")
let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!(
error
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}"
);
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]);
assert_eq!(
enabled
.config
.as_ref()
.and_then(|value| value.get("greeting"))
.and_then(|value| value.as_str()),
Some("hello")
}
#[test]
fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() {
let base =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
let mut top_level = base.clone();
top_level.as_object_mut().unwrap().insert(
"plugins".to_string(),
serde_json::json!({
"resolved": [{
"package_path": "/tmp/ambient.yoi-plugin"
}]
}),
);
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
let mut nested = base;
nested
.get_mut("feature")
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"plugins".to_string(),
serde_json::json!({ "enabled": true }),
);
let error = serde_json::from_value::<WorkerManifest>(nested).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
}
#[test]
fn dynamic_plugin_feature_flag_is_rejected() {
let toml = format!("{MINIMAL_REQUIRED}\n[feature.plugins]\nenabled = true\n");
let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!(
error
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}"
);
}
@@ -1246,36 +1542,237 @@ model_id = "claude-sonnet-4-20250514"
}
#[test]
fn omitted_memory_is_none() {
fn omitted_memory_feature_is_disabled() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.memory.is_none());
assert!(!manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.workspace_settings.is_none());
}
#[test]
fn empty_memory_section_enables_with_default_root() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
let toml = format!(
"{MINIMAL_REQUIRED}\n\
[feature.memory.profile]\n\
enabled = true\n\
staging_tools = false\n\n\
[feature.memory.profile.resident]\n\
inject_summary = false\n\n\
[feature.memory.profile.extraction]\n\
enabled = true\n\
threshold = 42000\n\
worker_max_turns = 2\n\n\
[feature.memory.workspace_settings]\n\
workspace_id = \"workspace-1\"\n\
settings_revision = 7\n\
language = \"日本語\"\n"
);
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.expect("memory section parsed");
assert!(mem.workspace_root.is_none());
assert_eq!(mem.inject_summary, None);
}
#[test]
fn memory_section_with_inject_summary_false() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!(mem.inject_summary, Some(false));
}
#[test]
fn memory_section_with_explicit_root() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert!(manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.memory.profile.resident.inject_summary);
assert_eq!(
mem.workspace_root.unwrap(),
std::path::PathBuf::from("/some/where")
manifest.feature.memory.profile.extraction.threshold,
Some(42_000)
);
assert_eq!(
manifest
.feature
.memory
.workspace_settings()
.unwrap()
.language,
"日本語"
);
}
#[test]
fn resolved_memory_execution_validation_fails_closed() {
let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
let mut enabled = ResolvedMemoryFeatureConfig::default();
enabled.profile.enabled = true;
assert!(enabled.validate_execution().is_err());
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
assert!(enabled.validate_execution().is_ok());
let mut disabled = ResolvedMemoryFeatureConfig::default();
disabled.workspace_settings = Some(snapshot.clone());
assert!(disabled.validate_execution().is_err());
assert!(disabled.bind_workspace_settings(snapshot).is_err());
}
#[test]
fn current_manifest_rejects_legacy_top_level_memory_authority() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
assert!(WorkerManifest::from_toml(&toml).is_err());
}
#[test]
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
let mut manifest =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
manifest["feature"]["memory"] = serde_json::json!({
"enabled": true,
"staging": true,
});
manifest["memory"] = serde_json::json!({
"workspace_root": "/discarded",
"query_result_limit": 999,
"inject_summary": false,
"workspace_id": "workspace-1",
"settings_revision": 9,
"language": "Français",
"extract_threshold": 1234,
"extract_worker_max_turns": 3,
"consolidation_threshold_files": 5,
"consolidation_threshold_bytes": 50000,
});
let migrated = read_persisted_worker_manifest_snapshot(manifest).unwrap();
assert!(migrated.feature.memory.profile.enabled);
assert!(migrated.feature.memory.profile.staging_tools);
assert!(!migrated.feature.memory.profile.resident.inject_summary);
assert_eq!(
migrated.feature.memory.profile.extraction.threshold,
Some(1234)
);
assert!(
migrated
.feature
.memory
.profile
.consolidation
.request_enabled
);
assert_eq!(
migrated
.feature
.memory
.workspace_settings()
.unwrap()
.language,
"Français"
);
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
assert_eq!(current["schema_version"], 3);
assert!(current["manifest"].get("memory").is_none());
let mut disabled =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
disabled["feature"]["memory"] = serde_json::json!({ "enabled": false });
disabled["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 9,
"language": "Français",
});
let disabled = read_persisted_worker_manifest_snapshot(disabled).unwrap();
assert!(!disabled.feature.memory.profile.enabled);
assert!(disabled.feature.memory.workspace_settings.is_none());
}
#[test]
fn persisted_manifest_adapter_drops_removed_plugin_authority() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let mut versioned = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
versioned["schema_version"] = serde_json::json!(2);
versioned["manifest"]["feature"]["plugins"] = serde_json::json!({ "enabled": true });
versioned["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("sub_worker");
versioned["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("worker");
versioned["manifest"]["feature"]["workers"] = serde_json::json!({ "enabled": true });
versioned["manifest"]["feature"]["ticket"] =
serde_json::json!({ "enabled": true, "access": "lifecycle" });
versioned["manifest"]["feature"]["ticket_orchestration"] =
serde_json::json!({ "enabled": false });
versioned["manifest"]["plugins"] = serde_json::json!({
"enabled": ["legacy-plugin"],
"config": { "legacy-plugin": { "legacy": true } }
});
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
assert_eq!(current["schema_version"], 3);
assert!(current["manifest"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("workers").is_none());
assert_eq!(
current["manifest"]["feature"]["sub_worker"]["enabled"],
true
);
assert_eq!(current["manifest"]["feature"]["worker"]["enabled"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["authoring"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["thread"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["workflow"], true);
let mut legacy = serde_json::to_value(manifest).unwrap();
legacy.as_object_mut().unwrap().remove("memory");
legacy["feature"]["memory"] = serde_json::json!({
"enabled": true,
"staging": false
});
legacy["feature"]["plugins"] = serde_json::json!({ "enabled": false });
legacy["plugins"] = serde_json::json!({ "enabled": [] });
let legacy = read_persisted_worker_manifest_snapshot(legacy).unwrap();
let current = write_persisted_worker_manifest_snapshot(&legacy).unwrap();
assert_eq!(
current["manifest"]["feature"]["memory"]["profile"]["enabled"],
false
);
}
#[test]
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
let manifest =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
let mut mixed = manifest.clone();
mixed["feature"]["memory"] = serde_json::json!({ "enabled": true, "profile": {} });
mixed["memory"] = serde_json::json!({});
assert!(read_persisted_worker_manifest_snapshot(mixed).is_err());
let mut custom_policy =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
custom_policy["feature"]["memory"] = serde_json::json!({ "enabled": true });
custom_policy["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 1,
"language": "English",
"consolidation_threshold_files": 99,
"consolidation_threshold_bytes": 50000,
});
assert!(read_persisted_worker_manifest_snapshot(custom_policy).is_err());
let current = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let mut current = write_persisted_worker_manifest_snapshot(&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]
fn reject_unknown_scheme() {
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,
};
use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig, paths,
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
paths,
};
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
@@ -148,7 +147,6 @@ pub enum WorkspaceAuthorityRequirement {
MergeRequest,
Objective,
Orchestration,
Plugins,
Ticket,
Worker,
}
@@ -162,7 +160,6 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
Self::MergeRequest => formatter.write_str("feature.merge_request"),
Self::Objective => formatter.write_str("feature.objective"),
Self::Orchestration => formatter.write_str("feature.orchestration"),
Self::Plugins => formatter.write_str("feature.plugins or plugin packages"),
Self::Ticket => formatter.write_str("feature.ticket"),
Self::Worker => formatter.write_str("feature.worker"),
}
@@ -185,7 +182,7 @@ pub fn validate_profile_execution_target(
if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
}
if feature.memory.enabled || feature.memory.staging {
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
requirements.insert(WorkspaceAuthorityRequirement::Memory);
}
if feature.merge_request.show
@@ -202,9 +199,6 @@ pub fn validate_profile_execution_target(
if feature.orchestration.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
}
if feature.plugins.enabled || !manifest.plugins.is_empty() {
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
}
if feature.ticket.enabled
|| feature.ticket.authoring
|| feature.ticket.thread
@@ -638,11 +632,9 @@ fn resolve_profile_value(
session: profile.session,
permissions: profile.permissions,
feature: profile.feature,
plugins: profile.plugins,
mcp: profile.mcp,
compaction,
web: profile.web,
memory: profile.memory.map(Into::into),
skills: profile.skills,
};
let config =
@@ -663,51 +655,6 @@ fn resolve_profile_value(
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileMemoryConfig {
#[serde(default)]
workspace_root: Option<PathBuf>,
#[serde(default)]
query_result_limit: Option<usize>,
#[serde(default)]
query_excerpt_lines: Option<usize>,
#[serde(default)]
inject_summary: Option<bool>,
#[serde(default)]
extract_model: Option<ModelManifest>,
#[serde(default)]
extract_threshold: Option<u64>,
#[serde(default)]
extract_worker_max_turns: Option<u32>,
#[serde(default)]
consolidation_model: Option<ModelManifest>,
#[serde(default)]
consolidation_threshold_files: Option<usize>,
#[serde(default)]
consolidation_threshold_bytes: Option<u64>,
}
impl From<ProfileMemoryConfig> for MemoryConfig {
fn from(profile: ProfileMemoryConfig) -> Self {
Self {
workspace_root: profile.workspace_root,
query_result_limit: profile.query_result_limit,
query_excerpt_lines: profile.query_excerpt_lines,
inject_summary: profile.inject_summary,
workspace_id: None,
settings_revision: None,
language: None,
extract_model: profile.extract_model,
extract_threshold: profile.extract_threshold,
extract_worker_max_turns: profile.extract_worker_max_turns,
consolidation_model: profile.consolidation_model,
consolidation_threshold_files: profile.consolidation_threshold_files,
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileConfig {
@@ -730,16 +677,12 @@ struct ProfileConfig {
#[serde(default)]
feature: FeatureConfigPartial,
#[serde(default)]
plugins: PluginConfig,
#[serde(default)]
mcp: McpConfig,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
#[serde(default)]
memory: Option<ProfileMemoryConfig>,
#[serde(default)]
skills: Option<SkillsConfig>,
}
@@ -940,12 +883,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
}
if let Some(memory) = &profile.memory
&& let Some(root) = &memory.workspace_root
&& root.is_absolute()
{
return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into()));
}
if let Some(skills) = &profile.skills {
for dir in &skills.directories {
if dir.is_absolute() {
@@ -1024,6 +961,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.join(path),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
});
}
Ok(ScopeConfig {
@@ -1031,6 +969,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.to_path_buf(),
permission,
recursive: true,
symlink_policy: Default::default(),
}],
deny,
})
@@ -1299,7 +1238,9 @@ mod tests {
("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")),
] {
let artifact = serde_json::json!({ "memory": { (field): value } });
let artifact = serde_json::json!({
"feature": { "memory": { (field): value } }
});
let error = resolve_profile_artifact_value(
artifact,
ProfileSource::Registry {
@@ -1319,6 +1260,51 @@ mod tests {
}
}
#[test]
fn ambient_plugin_directories_do_not_affect_builtin_profile_resolution() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace/nested");
std::fs::create_dir_all(&workspace).unwrap();
for root in [tmp.path(), tmp.path().join("workspace").as_path()] {
let package = root.join(".yoi/plugins/broken.yoi-plugin");
std::fs::create_dir_all(package.parent().unwrap()).unwrap();
std::fs::write(package, b"malformed ambient package").unwrap();
}
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve_for_target(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
ProfileResolveOptions::with_worker_name("standalone-worker"),
ProfileExecutionTarget::Standalone,
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "standalone-worker");
}
#[test]
fn profile_rejects_dynamic_plugin_configuration() {
let tmp = TempDir::new().unwrap();
for body in [
"[feature.plugins]\nenabled = true\n",
"[[plugins.enabled]]\nid = \"explicit:example\"\n",
] {
let profile = write_profile(tmp.path(), "plugin.toml", body);
let error = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
}
#[test]
fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
let tmp = TempDir::new().unwrap();
@@ -1351,14 +1337,12 @@ mod tests {
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
}));
assert!(!resolved.manifest.feature.memory.enabled);
assert!(!resolved.manifest.feature.memory.profile.enabled);
assert!(!resolved.manifest.feature.ticket.enabled);
assert!(!resolved.manifest.feature.objective.enabled);
assert!(!resolved.manifest.feature.flow.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
assert!(!resolved.manifest.feature.manage_workdir.enabled);
assert!(!resolved.manifest.feature.plugins.enabled);
assert!(resolved.manifest.plugins.is_empty());
}
#[test]
@@ -1630,7 +1614,7 @@ enabled = false
.unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert!(resolved.manifest.feature.task.enabled);
assert!(!resolved.manifest.feature.memory.enabled);
assert!(!resolved.manifest.feature.memory.profile.enabled);
assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.feature.ticket.enabled);
+271 -69
View File
@@ -3,16 +3,17 @@
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
//! possible) so access checks are pure path comparisons.
//! All rule targets retain both their lexically normalized logical identity and
//! their provider-resolved identity. Allow rules select one identity explicitly;
//! deny rules always inspect both so aliases cannot bypass a restriction.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use arc_swap::{ArcSwap, Guard};
use crate::{Permission, ScopeConfig, ScopeRule};
use crate::{Permission, ScopeConfig, ScopeRule, SymlinkPolicy};
/// Parsed, pwd-resolved set of allow/deny rules for a Worker.
///
@@ -26,10 +27,13 @@ pub struct Scope {
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedRule {
/// Absolute, canonicalized-or-normalized target directory/file.
target: PathBuf,
/// Absolute, lexically normalized target as presented through the Workdir.
logical_target: PathBuf,
/// Absolute target after provider-side symbolic-link resolution.
resolved_target: PathBuf,
permission: Permission,
recursive: bool,
symlink_policy: SymlinkPolicy,
}
/// Parsed filesystem authority this Worker may pass to spawned children.
@@ -98,18 +102,46 @@ fn permission_denies_requested(denied: Permission, requested: Permission) -> boo
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_covers(available.permission, requested.permission)
&& rule_path_set_contains(available, requested)
&& available.symlink_policy >= requested.symlink_policy
&& rule_path_set_contains(
available,
requested,
match available.symlink_policy {
SymlinkPolicy::Resolved => RuleIdentity::Resolved,
SymlinkPolicy::Logical => RuleIdentity::Logical,
},
)
}
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_denies_requested(deny.permission, requested.permission)
&& rule_path_sets_overlap(deny, requested)
&& (rule_path_sets_overlap(deny, requested, RuleIdentity::Logical)
|| rule_path_sets_overlap(deny, requested, RuleIdentity::Resolved))
}
fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
#[derive(Clone, Copy)]
enum RuleIdentity {
Logical,
Resolved,
}
fn rule_target(rule: &ResolvedRule, identity: RuleIdentity) -> &Path {
match identity {
RuleIdentity::Logical => &rule.logical_target,
RuleIdentity::Resolved => &rule.resolved_target,
}
}
fn rule_path_set_contains(
available: &ResolvedRule,
requested: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let available_target = rule_target(available, identity);
let requested_target = rule_target(requested, identity);
match (available.recursive, requested.recursive) {
// A recursive grant contains every possible requested path below its target.
(true, _) => requested.target.starts_with(&available.target),
(true, _) => requested_target.starts_with(available_target),
// A non-recursive grant contains only the target and its direct children;
// a recursive request always includes descendants beyond that finite-depth
// set.
@@ -117,36 +149,42 @@ fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) ->
// Two non-recursive rules have the same finite-depth set only when their
// target is identical. A request rooted at a direct child would also grant
// that child's children, which are grandchildren of `available.target`.
(false, false) => requested.target == available.target,
(false, false) => requested_target == available_target,
}
}
fn rule_path_sets_overlap(left: &ResolvedRule, right: &ResolvedRule) -> bool {
fn rule_path_sets_overlap(
left: &ResolvedRule,
right: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let left_target = rule_target(left, identity);
let right_target = rule_target(right, identity);
match (left.recursive, right.recursive) {
(true, true) => {
left.target.starts_with(&right.target) || right.target.starts_with(&left.target)
left_target.starts_with(right_target) || right_target.starts_with(left_target)
}
(true, false) => recursive_and_non_recursive_sets_overlap(left, right),
(false, true) => recursive_and_non_recursive_sets_overlap(right, left),
(true, false) => recursive_and_non_recursive_sets_overlap(left_target, right_target),
(false, true) => recursive_and_non_recursive_sets_overlap(right_target, left_target),
(false, false) => {
left.target == right.target
|| direct_child(&left.target, &right.target)
|| direct_child(&right.target, &left.target)
left_target == right_target
|| direct_child(left_target, right_target)
|| direct_child(right_target, left_target)
}
}
}
fn recursive_and_non_recursive_sets_overlap(
recursive: &ResolvedRule,
non_recursive: &ResolvedRule,
recursive_target: &Path,
non_recursive_target: &Path,
) -> bool {
// The non-recursive set is `{target} + direct children`. It overlaps a
// recursive subtree when either the non-recursive target is inside that
// subtree, or the recursive subtree begins at the non-recursive target or
// one of its direct children.
non_recursive.target.starts_with(&recursive.target)
|| recursive.target == non_recursive.target
|| direct_child(&recursive.target, &non_recursive.target)
non_recursive_target.starts_with(recursive_target)
|| recursive_target == non_recursive_target
|| direct_child(recursive_target, non_recursive_target)
}
fn direct_child(child: &Path, parent: &Path) -> bool {
@@ -201,23 +239,35 @@ impl Scope {
}
/// Convenience constructor for tests and simple setups: a single
/// recursive `allow(Write)` rule rooted at `root`.
/// recursive `allow(Write)` rule rooted at `root` with the default
/// resolved-target symlink policy.
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
let root = root.as_ref().canonicalize()?;
let root = normalize_path(root.as_ref()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"scope root must be an absolute path without root traversal",
)
})?;
let resolved_root = resolve_path(&root)?;
Ok(Self {
allow: vec![ResolvedRule {
target: root,
logical_target: root,
resolved_target: resolved_root,
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}],
deny: Vec::new(),
})
}
/// Resolve one rule target with the same symlink and missing-tail semantics
/// used by scope matching.
/// Return one rule target in the identity selected by its symlink policy.
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
Ok(resolve_rule(rule)?.target)
let rule = resolve_rule(rule)?;
Ok(match rule.symlink_policy {
SymlinkPolicy::Resolved => rule.resolved_target,
SymlinkPolicy::Logical => rule.logical_target,
})
}
/// Return whether this effective scope fully contains a requested rule.
@@ -244,10 +294,23 @@ impl Scope {
/// Returns `None` when `path` is outside every allow rule, or when
/// deny rules have knocked it below `Read`.
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
let resolved = resolve_path(path)?;
let logical = normalize_path(path)?;
let resolved = resolve_path(&logical).ok()?;
self.permission_at_paths(&logical, &resolved)
}
/// Effective permission for a path whose logical and provider-resolved
/// identities were obtained inside the filesystem provider boundary.
pub fn permission_at_paths(&self, logical: &Path, resolved: &Path) -> Option<Permission> {
let logical = normalize_path(logical)?;
let resolved = normalize_path(resolved)?;
let mut effective: Option<Permission> = None;
for rule in &self.allow {
if rule.matches(&resolved) {
let candidate = match rule.symlink_policy {
SymlinkPolicy::Resolved => &resolved,
SymlinkPolicy::Logical => &logical,
};
if rule.matches(candidate, rule.symlink_policy) {
effective = match effective {
None => Some(rule.permission),
Some(cur) => Some(cur.max(rule.permission)),
@@ -256,11 +319,13 @@ impl Scope {
}
let mut effective = effective?;
// Deny: min(min_deny) dictates the cap. Effective level is capped
// strictly below that value, so deny(read) wipes access entirely.
// Deny rules always inspect both identities. This prevents a logical
// alias or a second symlink to the same target from bypassing a deny.
let mut min_deny: Option<Permission> = None;
for rule in &self.deny {
if rule.matches(&resolved) {
if rule.matches(&logical, SymlinkPolicy::Logical)
|| rule.matches(&resolved, SymlinkPolicy::Resolved)
{
min_deny = match min_deny {
None => Some(rule.permission),
Some(cur) => Some(cur.min(rule.permission)),
@@ -293,7 +358,7 @@ impl Scope {
/// rule, preserving declaration order. Does not account for deny
/// rules, which only cap effective permission at query time.
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
self.allow.iter().map(|r| r.target.as_path())
self.allow.iter().map(|r| r.logical_target.as_path())
}
/// Allow rules with their targets resolved to absolute paths.
@@ -305,9 +370,10 @@ impl Scope {
self.allow
.iter()
.map(|r| ScopeRule {
target: r.target.clone(),
target: r.logical_target.clone(),
permission: r.permission,
recursive: r.recursive,
symlink_policy: r.symlink_policy,
})
.collect()
}
@@ -322,9 +388,10 @@ impl Scope {
self.deny
.iter()
.map(|r| ScopeRule {
target: r.target.clone(),
target: r.logical_target.clone(),
permission: r.permission,
recursive: r.recursive,
symlink_policy: r.symlink_policy,
})
.collect()
}
@@ -335,7 +402,7 @@ impl Scope {
self.allow
.iter()
.filter(|r| r.permission == Permission::Write)
.map(|r| r.target.as_path())
.map(|r| r.logical_target.as_path())
}
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended
@@ -412,7 +479,10 @@ impl Scope {
pub fn summary(&self) -> String {
fn push_rule(out: &mut String, rule: &ResolvedRule) {
out.push_str(" - ");
out.push_str(&rule.target.display().to_string());
out.push_str(&rule.logical_target.display().to_string());
if rule.symlink_policy == SymlinkPolicy::Logical {
out.push_str(" [logical-symlinks]");
}
if !rule.recursive {
out.push_str(" [non-recursive]");
}
@@ -510,11 +580,15 @@ impl SharedScope {
}
impl ResolvedRule {
fn matches(&self, path: &Path) -> bool {
fn matches(&self, path: &Path, identity: SymlinkPolicy) -> bool {
let target = match identity {
SymlinkPolicy::Resolved => &self.resolved_target,
SymlinkPolicy::Logical => &self.logical_target,
};
if self.recursive {
path.starts_with(&self.target)
path.starts_with(target)
} else {
path == self.target || path.parent() == Some(self.target.as_path())
path == target || path.parent() == Some(target.as_path())
}
}
}
@@ -523,48 +597,84 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
if !rule.target.is_absolute() {
return Err(ScopeError::RelativeTarget(rule.target.clone()));
}
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
let logical_target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
path: rule.target.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
})?;
let resolved_target =
resolve_path(&logical_target).map_err(|source| ScopeError::ResolveTarget {
path: rule.target.clone(),
source,
})?;
Ok(ResolvedRule {
target,
logical_target,
resolved_target,
permission: rule.permission,
recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
})
}
/// Convert `path` to an absolute form suitable for prefix comparison.
///
/// Tries `canonicalize` on the full path first (resolves symlinks). If
/// the path doesn't exist yet, climbs to the closest existing ancestor,
/// canonicalizes it, then rejoins the missing tail. Returns `None` for
/// relative inputs that have no existing ancestor to anchor against.
fn resolve_path(path: &Path) -> Option<PathBuf> {
/// Resolve every existing path component while retaining a missing final tail.
/// A dangling symlink is rejected rather than treated as an ordinary missing
/// component because its resolved authority cannot be established.
fn resolve_path(path: &Path) -> std::io::Result<PathBuf> {
let mut cursor = path;
let mut missing = Vec::<OsString>::new();
loop {
match std::fs::canonicalize(cursor) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return normalize_path(&resolved).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"resolved target is not an absolute normalized path",
)
});
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(cursor)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(error);
}
let name = cursor.file_name().ok_or(error)?;
missing.push(name.to_os_string());
cursor = cursor.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"scope target has no existing ancestor",
)
})?;
}
Err(error) => return Err(error),
}
}
}
/// Normalize an absolute path for lexical scope comparison without consulting
/// filesystem metadata or resolving symbolic links.
fn normalize_path(path: &Path) -> Option<PathBuf> {
if !path.is_absolute() {
return None;
}
if let Ok(canonical) = path.canonicalize() {
return Some(canonical);
}
let mut tail: Vec<OsString> = Vec::new();
let mut cur = path.to_path_buf();
loop {
if let Ok(canonical) = cur.canonicalize() {
let mut out = canonical;
for segment in tail.iter().rev() {
out.push(segment);
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
return None;
}
}
return Some(out);
Component::Normal(part) => normalized.push(part),
}
let name = cur.file_name()?.to_os_string();
tail.push(name);
let parent = cur.parent()?.to_path_buf();
if parent == cur {
return None;
}
cur = parent;
}
normalized.is_absolute().then_some(normalized)
}
#[cfg(test)]
@@ -577,6 +687,7 @@ mod tests {
target: target.to_path_buf(),
permission,
recursive,
symlink_policy: Default::default(),
}
}
@@ -691,6 +802,7 @@ mod tests {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: false,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
};
@@ -790,6 +902,7 @@ mod tests {
target: PathBuf::from("relative/path"),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
};
@@ -805,6 +918,88 @@ mod tests {
assert!(!scope.is_readable(&traversal));
}
#[cfg(unix)]
#[test]
fn scope_defaults_to_resolved_symlink_authority_and_logical_is_explicit() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
symlink(outside.path(), dir.path().join("external")).unwrap();
let resolved = Scope::writable(dir.path()).unwrap();
assert!(!resolved.is_readable(&dir.path().join("external/outside.txt")));
assert!(!resolved.is_writable(&dir.path().join("external/new.txt")));
let logical = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: Vec::new(),
})
.unwrap();
assert!(logical.is_readable(&dir.path().join("external/outside.txt")));
assert!(logical.is_writable(&dir.path().join("external/new.txt")));
assert!(!logical.is_readable(&outside.path().join("outside.txt")));
assert!(!logical.is_writable(&outside.path().join("new.txt")));
}
#[cfg(unix)]
#[test]
fn deny_rules_match_both_logical_alias_and_resolved_target() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let secret = root.path().join("secret");
std::fs::create_dir(&secret).unwrap();
std::fs::write(secret.join("key"), "hidden").unwrap();
symlink(&secret, root.path().join("alias")).unwrap();
let scope = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: vec![ScopeRule {
target: secret,
permission: Permission::Read,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
})
.unwrap();
assert!(!scope.is_readable(&root.path().join("alias/key")));
}
#[test]
fn delegation_symlink_policy_is_monotonically_attenuated() {
let root = TempDir::new().unwrap();
let mut parent_rule = allow_rule(root.path(), Permission::Write);
parent_rule.symlink_policy = SymlinkPolicy::Logical;
let logical_parent = DelegationScope::from_config(&ScopeConfig {
allow: vec![parent_rule],
deny: Vec::new(),
})
.unwrap();
let resolved_child = allow_rule(&root.path().join("child"), Permission::Read);
assert!(logical_parent.allows_rule(&resolved_child).unwrap());
let resolved_parent = DelegationScope::from_config(&ScopeConfig {
allow: vec![allow_rule(root.path(), Permission::Write)],
deny: Vec::new(),
})
.unwrap();
let mut logical_child = resolved_child;
logical_child.symlink_policy = SymlinkPolicy::Logical;
assert!(!resolved_parent.allows_rule(&logical_child).unwrap());
}
#[test]
fn summary_lists_readable_and_writable() {
let dir = TempDir::new().unwrap();
@@ -851,11 +1046,13 @@ mod tests {
target: docs.clone(),
permission: Permission::Read,
recursive: false,
symlink_policy: Default::default(),
},
ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
},
],
deny: Vec::new(),
@@ -914,6 +1111,7 @@ mod tests {
target: extra.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}])
.unwrap();
assert!(extended.is_readable(&extra.path().join("x")));
@@ -931,6 +1129,7 @@ mod tests {
target: sub.clone(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}])
.unwrap();
let f = sub.join("a.txt");
@@ -950,6 +1149,7 @@ mod tests {
target: sub.clone(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
};
let base = Scope::writable(dir.path())
.unwrap()
@@ -1003,6 +1203,7 @@ mod tests {
target: sub.clone(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}])
})
.unwrap();
@@ -1021,6 +1222,7 @@ mod tests {
target: extra.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}])
})
.unwrap();
+13 -5
View File
@@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidateStagingOperation {
#[serde(default)]
pub force: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_files: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -450,10 +447,21 @@ mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate};
#[test]
fn consolidation_operation_rejects_caller_owned_thresholds() {
let error =
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
"force": false,
"threshold_files": 1,
}))
.unwrap_err();
assert!(error.to_string().contains("threshold_files"));
}
#[test]
fn staging_list_read_close_records_reason_and_deletes_candidate() {
let temp = tempfile::tempdir().unwrap();
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path());
let layout = WorkspaceLayout::resolve(temp.path());
let source = SourceRef {
segment_id: "segment-1".into(),
range: [0, 1],
+1 -2
View File
@@ -21,8 +21,7 @@ pub struct StagingEntry {
pub id: Uuid,
pub path: PathBuf,
pub record: StagingRecord,
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`)
/// に使う。
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
pub bytes: u64,
}
+1
View File
@@ -23,6 +23,7 @@ fn deny_write(target: &Path) -> ScopeRule {
target: target.to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}
}
+8 -33
View File
@@ -70,24 +70,12 @@ impl WorkspaceLayout {
Self { root: root.into() }
}
/// Resolve a layout from a `MemoryConfig`.
/// Resolve a layout from the nearest Memory marker.
///
/// An explicit `memory.workspace_root` is honored exactly. Without an
/// explicit root, resolution searches `default_root` and its ancestors for
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
/// contain `.yoi` project records such as tickets from
/// becoming independent memory roots merely because they contain `.yoi`.
///
/// If no memory marker exists, this falls back to `default_root` because
/// existing call sites require a concrete layout. That fallback is a
/// no-marker compatibility path, not a `.yoi` marker interpretation; it
/// must not be used as evidence that `.yoi` alone enables repo-local
/// memory.
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
if let Some(root) = &cfg.workspace_root {
return Self::new(root.clone());
}
/// Resolution searches `default_root` and its ancestors for the nearest
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
/// policy directly; resolved Worker Manifests do not carry storage paths.
pub fn resolve(default_root: &Path) -> Self {
let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root)
@@ -335,16 +323,6 @@ mod tests {
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn resolve_uses_workspace_root_when_set() {
let cfg = manifest::MemoryConfig {
workspace_root: Some(PathBuf::from("/explicit")),
..Default::default()
};
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
assert_eq!(layout.root(), Path::new("/explicit"));
}
#[test]
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
let tmp = TempDir::new().unwrap();
@@ -353,8 +331,7 @@ mod tests {
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(&child).unwrap();
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), workspace.as_path());
}
@@ -366,8 +343,7 @@ mod tests {
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), workspace.as_path());
}
@@ -381,8 +357,7 @@ mod tests {
assert_eq!(find_memory_marker_root(&child), None);
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), child.as_path());
}
}
+643 -108
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -573,6 +573,11 @@ pub struct SubscriptionWorker {
pub resource_key: Option<String>,
/// Producer-owned monotonic revision for this Worker subject.
pub subject_revision: u64,
/// Latest revisioned foreground state observed from the Worker. This remains
/// absent until an authoritative Worker snapshot/event has been applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<crate::WorkerStateSnapshot>,
/// Runtime catalog lifecycle compatibility projection; not foreground-state authority.
pub state: SubscriptionWorkerState,
#[serde(default)]
pub has_running_internal_workers: bool,
@@ -874,6 +879,7 @@ mod tests {
runtime_id: None,
resource_key: None,
subject_revision: 0,
worker_state: None,
state: SubscriptionWorkerState::Idle,
has_running_internal_workers: false,
workspace_id: Some("workspace-1".to_string()),
+26 -7
View File
@@ -4,15 +4,18 @@ use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent,
WorkerStatus,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -46,12 +49,22 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<WorkerStatus>(&cfg, &mut output);
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
push_decl::<WorkerCommandKind>(&cfg, &mut output);
push_decl::<WorkerCommandDisposition>(&cfg, &mut output);
push_decl::<WorkerCommandAcknowledgement>(&cfg, &mut output);
push_decl::<WorkerRunState>(&cfg, &mut output);
push_decl::<WorkerMaintenanceState>(&cfg, &mut output);
push_decl::<WorkerBusyState>(&cfg, &mut output);
push_decl::<WorkerState>(&cfg, &mut output);
push_decl::<WorkerStateSnapshot>(&cfg, &mut output);
push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ToolResultDisposition>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output);
push_decl::<SymlinkPolicy>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output);
push_decl::<CommandStatus>(&cfg, &mut output);
push_decl::<CommandStream>(&cfg, &mut output);
@@ -60,6 +73,8 @@ pub fn generated_protocol_types() -> String {
push_decl::<CommandEvent>(&cfg, &mut output);
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
push_decl::<CompactionLifecycle>(&cfg, &mut output);
push_decl::<CompactionPhase>(&cfg, &mut output);
push_decl::<CompactionTrigger>(&cfg, &mut output);
push_decl::<UploadedFileAvailability>(&cfg, &mut output);
push_decl::<UploadedFileRef>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output);
@@ -68,6 +83,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightCompaction>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
push_decl::<SessionMessageRole>(&cfg, &mut output);
@@ -75,6 +91,9 @@ pub fn generated_protocol_types() -> String {
push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
push_decl::<SubmissionDisposition>(&cfg, &mut output);
push_decl::<SessionSnapshot>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output);
+3
View File
@@ -8,3 +8,6 @@ license.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
session-store = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+32 -22
View File
@@ -1,29 +1,39 @@
# session-metrics
## Role
Session 単位の append-only な観測値を既存 session-log に記録し、明示的な
metrics 読取 / JSONL export 経路で取り出すための小さなヘルパークレートです。
`session-metrics` records usage and memory/session metrics that are useful for diagnostics and maintenance.
- 保存先は `session-store``LogEntry::Extension`
- extension domain は `metrics`
- metric は `name / ts / dimensions / value / correlation_id` の最小 envelope
- `record_metric` で指定した Session / Segment に append する
- `read_segment_metrics` は 1 Segment、`read_session_metrics` は Session 内の全
Segment を読み、各 metric に `segment_id``compacted_from` を付ける
- `export_metrics_jsonl` はその located metric を newline-delimited JSON にする
- 通常の Session snapshot / Worker list / Worker detail は Extension を公開しない
## Boundaries
compaction は `compact.start` を source Segment、`compact.finish`
`compact.post_request` を結果 Segment に記録する。同じ `correlation_id`
`SegmentStart.compacted_from` により、Segment をまたぐ attempt と次の通常 LLM
request を結合できる。
Owns:
```rust,ignore
use session_metrics::{
Metric, export_metrics_jsonl, read_session_metrics, record_metric,
};
- metric record types and persistence helpers
- explicit memory usage/read/reference observations where applicable
- lightweight diagnostic data that should not become model context by itself
let metric = Metric::now("compact.start")
.with_value(12_345.0)
.with_dimension("trigger", "pre_run")
.with_correlation_id("018f6f8a-9822-7b11-8b35-706f30313700");
record_metric(
&store,
location.session_id,
location.segment_id,
&metric,
)?;
Does not own:
- prompt context packing (`agen`)
- generated memory contents (`memory`)
- provider billing semantics (`provider`)
- UI status rendering (`tui`)
## Design notes
Metrics are observations. They may guide compaction, memory effectiveness analysis, or UX, but they are not authoritative conversation history and should not smuggle hidden state into model input.
## See also
- [`../../docs/design/memory-knowledge.md`](../../docs/design/memory-knowledge.md)
- [`../../docs/design/compaction.md`](../../docs/design/compaction.md)
let records = read_session_metrics(&store, location.session_id)?;
let jsonl = export_metrics_jsonl(&records)?;
# Ok::<(), Box<dyn std::error::Error>>(())
```
+260 -3
View File
@@ -12,13 +12,16 @@
//! 観測できない値は `None` で明示する
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
//! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join
//! - 集計 / 可視化 API はこのクレートには無い。session-log を読めば取り出せる、
//! までが到達点
//! - 集計 / 可視化には [`read_session_metrics`] / [`read_segment_metrics`] /
//! [`export_metrics_jsonl`] の明示的な metrics 専用経路を使う。通常の
//! Session snapshot は `Extension` を公開しない。
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId, Store, StoreError, save_extension, segment_log};
use session_store::{
LogEntry, SegmentId, SegmentOrigin, SessionId, Store, StoreError, save_extension, segment_log,
};
/// Domain tag used in `LogEntry::Extension` for all metrics records.
pub const DOMAIN: &str = "metrics";
@@ -97,6 +100,165 @@ pub fn metrics_from_extensions(extensions: &[(String, serde_json::Value)]) -> Ve
.collect()
}
/// A metric together with its durable Session/Segment origin.
///
/// `compacted_from` is copied from the Segment start record so readers can
/// reconstruct compaction lineage without inferring relationships from metric
/// names or timestamps.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LocatedMetric {
pub session_id: SessionId,
pub segment_id: SegmentId,
#[serde(skip_serializing_if = "Option::is_none")]
pub compacted_from: Option<SegmentOrigin>,
pub log_index: usize,
pub metric: Metric,
}
#[derive(Debug)]
pub enum SessionMetricsError {
Store(StoreError),
MissingSegmentStart {
segment_id: SegmentId,
},
SessionMismatch {
requested: SessionId,
observed: SessionId,
segment_id: SegmentId,
},
Encode(serde_json::Error),
}
impl std::fmt::Display for SessionMetricsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Store(error) => write!(formatter, "session metrics store error: {error}"),
Self::MissingSegmentStart { segment_id } => {
write!(formatter, "segment {segment_id} has no start record")
}
Self::SessionMismatch {
requested,
observed,
segment_id,
} => write!(
formatter,
"segment {segment_id} belongs to session {observed}, not {requested}"
),
Self::Encode(error) => write!(formatter, "session metrics encode error: {error}"),
}
}
}
impl std::error::Error for SessionMetricsError {}
impl From<StoreError> for SessionMetricsError {
fn from(error: StoreError) -> Self {
Self::Store(error)
}
}
impl From<serde_json::Error> for SessionMetricsError {
fn from(error: serde_json::Error) -> Self {
Self::Encode(error)
}
}
/// Read metrics from one exact Segment.
///
/// This is an explicit metrics-only surface. It validates the Segment's
/// durable start record and retains the log position of each metric.
pub fn read_segment_metrics(
store: &dyn Store,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
let entries = store.read_all(session_id, segment_id)?;
let (observed_session_id, compacted_from) = entries
.iter()
.find_map(|entry| match entry {
LogEntry::AnnotatedSegmentStart {
session_id,
compacted_from,
..
} => Some((*session_id, compacted_from.clone())),
_ => None,
})
.ok_or(SessionMetricsError::MissingSegmentStart { segment_id })?;
if observed_session_id != session_id {
return Err(SessionMetricsError::SessionMismatch {
requested: session_id,
observed: observed_session_id,
segment_id,
});
}
Ok(entries
.iter()
.enumerate()
.filter_map(|(log_index, entry)| match entry {
LogEntry::Extension {
domain, payload, ..
} if domain == DOMAIN => {
serde_json::from_value::<Metric>(payload.clone())
.ok()
.map(|metric| LocatedMetric {
session_id,
segment_id,
compacted_from: compacted_from.clone(),
log_index,
metric,
})
}
_ => None,
})
.collect())
}
/// Read every metric for a Session across all of its Segments.
pub fn read_session_metrics(
store: &dyn Store,
session_id: SessionId,
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
let mut metrics = Vec::new();
for segment_id in store.list_segments(session_id)? {
metrics.extend(read_segment_metrics(store, session_id, segment_id)?);
}
metrics.sort_by(|left, right| {
(
left.metric.ts,
metric_phase_order(&left.metric.name),
left.segment_id,
left.log_index,
)
.cmp(&(
right.metric.ts,
metric_phase_order(&right.metric.name),
right.segment_id,
right.log_index,
))
});
Ok(metrics)
}
/// Serialize located metrics as newline-delimited JSON for an explicit export.
pub fn export_metrics_jsonl(metrics: &[LocatedMetric]) -> Result<String, SessionMetricsError> {
let mut output = String::new();
for metric in metrics {
output.push_str(&serde_json::to_string(metric)?);
output.push('\n');
}
Ok(output)
}
fn metric_phase_order(name: &str) -> u8 {
match name {
"compact.start" => 0,
"compact.finish" => 2,
"compact.post_request" => 3,
_ => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -151,6 +313,101 @@ mod tests {
assert_eq!(metrics[1].name, "b");
}
#[test]
fn explicit_reader_and_export_preserve_compaction_lineage() {
use session_store::FsStore;
let temp = tempfile::tempdir().unwrap();
let store = FsStore::new(temp.path()).unwrap();
let session_id = SessionId::parse_str("018f6f8a-9822-7b11-8b35-706f30313701").unwrap();
let source_segment_id =
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313702").unwrap();
let result_segment_id =
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313703").unwrap();
let correlation_id = "018f6f8a-9822-7b11-8b35-706f30313700";
store
.create_segment(
session_id,
source_segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: Default::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
let mut start = Metric::now("compact.start").with_correlation_id(correlation_id);
start.ts = 10;
record_metric(&store, session_id, source_segment_id, &start).unwrap();
let origin = SegmentOrigin {
segment_id: source_segment_id,
at_turn_index: 0,
};
store
.create_segment(
session_id,
result_segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 2,
session_id,
system_prompt: None,
config: Default::default(),
history: Vec::new(),
forked_from: None,
compacted_from: Some(origin.clone()),
}],
)
.unwrap();
let mut finish = Metric::now("compact.finish").with_correlation_id(correlation_id);
finish.ts = 10;
record_metric(&store, session_id, result_segment_id, &finish).unwrap();
let mut post = Metric::now("compact.post_request").with_correlation_id(correlation_id);
post.ts = 11;
record_metric(&store, session_id, result_segment_id, &post).unwrap();
let source_metrics = read_segment_metrics(&store, session_id, source_segment_id).unwrap();
assert_eq!(source_metrics.len(), 1);
assert_eq!(source_metrics[0].compacted_from, None);
let metrics = read_session_metrics(&store, session_id).unwrap();
assert_eq!(metrics.len(), 3);
assert_eq!(metrics[0].metric.name, "compact.start");
let finish = metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.segment_id, result_segment_id);
assert_eq!(finish.compacted_from, Some(origin));
assert!(
metrics
.iter()
.all(|record| { record.metric.correlation_id.as_deref() == Some(correlation_id) })
);
let exported = export_metrics_jsonl(&metrics).unwrap();
let ordinary_snapshot = session_store::public_snapshot::project_current_session_snapshot(
&store.read_all(session_id, result_segment_id).unwrap(),
);
let ordinary_json = serde_json::to_string(&ordinary_snapshot).unwrap();
assert!(!ordinary_json.contains("compact.finish"));
assert!(!ordinary_json.contains("compact.post_request"));
let decoded = exported
.lines()
.map(|line| serde_json::from_str::<LocatedMetric>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(decoded, metrics);
let reopened = FsStore::new(temp.path()).unwrap();
let restored = read_session_metrics(&reopened, session_id).unwrap();
assert_eq!(restored, metrics);
}
#[test]
fn fold_skips_undeserializable_payloads() {
// 将来 schema が変わって読めない payload も skip して落ちない。
+171 -9
View File
@@ -21,8 +21,10 @@ use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError};
use crate::uploaded_file::{
bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files,
delete_uncommitted_uploaded_files, delete_uploaded_file, list_uploaded_file_refs,
read_uploaded_file, read_uploaded_file_by_id, write_uploaded_file,
delete_uncommitted_uploaded_files, delete_uploaded_file, finalize_uploaded_file_binding,
list_uploaded_file_refs, pin_uploaded_file, read_uploaded_file, read_uploaded_file_by_id,
reconcile_uploaded_file_pins, release_uploaded_file_pin, uploaded_file_has_pending_owner,
write_uploaded_file,
};
use crate::{
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
@@ -518,6 +520,61 @@ impl Store for FsStore {
}
}
fn pin_uploaded_file(
&self,
session_id: SessionId,
reference: &UploadedFileRef,
owner_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
pin_uploaded_file(&self.paste_artifact_dir(session_id), reference, owner_id)
}
fn release_uploaded_file_pin(
&self,
session_id: SessionId,
artifact_id: &str,
owner_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
release_uploaded_file_pin(&self.paste_artifact_dir(session_id), artifact_id, owner_id)
}
fn finalize_uploaded_file_binding(
&self,
session_id: SessionId,
artifact_id: &str,
source_entry_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
finalize_uploaded_file_binding(
&self.paste_artifact_dir(session_id),
artifact_id,
source_entry_id,
)
}
fn reconcile_uploaded_file_pins(
&self,
session_id: SessionId,
live_owner_ids: &[String],
) -> Result<u64, StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
reconcile_uploaded_file_pins(&self.paste_artifact_dir(session_id), live_owner_ids)
}
fn delete_uploaded_file(
&self,
session_id: SessionId,
@@ -541,13 +598,18 @@ impl Store for FsStore {
let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
continue;
};
if !self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
if delete_uploaded_file(&dir, &reference.artifact_id)? {
removed = removed
.checked_add(1)
.ok_or(StoreError::ArtifactQuotaExceeded)?;
}
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
finalize_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
continue;
}
if uploaded_file_has_pending_owner(&dir, &reference.artifact_id)? {
continue;
}
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
if delete_uploaded_file(&dir, &reference.artifact_id)? {
removed = removed
.checked_add(1)
.ok_or(StoreError::ArtifactQuotaExceeded)?;
}
}
Ok(removed)
@@ -865,6 +927,106 @@ mod tests {
assert!(store.read_uploaded_file(owner, &reference).is_err());
}
#[test]
fn pending_upload_pin_survives_cleanup_until_release_or_history_binding() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsStore::new(tmp.path()).unwrap();
let session_id = new_session_id();
let limits = UploadedFileLimits {
max_file_bytes: 64,
max_session_bytes: 128,
};
let pending = store
.write_uploaded_file(session_id, "pending.txt", "text/plain", b"pending", limits)
.unwrap();
store
.pin_uploaded_file(session_id, &pending, "submission-1")
.unwrap();
assert!(matches!(
store.pin_uploaded_file(session_id, &pending, "submission-other"),
Err(StoreError::ArtifactAlreadyCommitted)
));
drop(store);
let store = FsStore::new(tmp.path()).unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert_eq!(
store
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
.unwrap()
.1,
b"pending"
);
let fork_session_id = new_session_id();
assert_eq!(
store
.copy_committed_uploaded_files(session_id, fork_session_id)
.unwrap(),
0
);
assert!(
store
.read_uploaded_file_by_id(fork_session_id, &pending.artifact_id)
.is_err()
);
let committed = store
.bind_uploaded_file(session_id, &pending, "entry-1")
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert!(
store
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
.is_ok()
);
store
.create_segment(
session_id,
new_segment_id(),
&[LogEntry::InputSegmentsCheckpoint {
ts: 1,
user_segments: vec![vec![protocol::Segment::UploadedFile {
file: committed.clone(),
}]],
}],
)
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert!(
store
.release_uploaded_file_pin(session_id, &pending.artifact_id, "submission-1")
.is_err()
);
let releasable = store
.write_uploaded_file(session_id, "cancelled.txt", "text/plain", b"cancel", limits)
.unwrap();
store
.pin_uploaded_file(session_id, &releasable, "submission-2")
.unwrap();
store
.release_uploaded_file_pin(session_id, &releasable.artifact_id, "submission-2")
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
1
);
assert!(
store
.read_uploaded_file_by_id(session_id, &releasable.artifact_id)
.is_err()
);
}
#[test]
fn uploaded_file_validation_and_shared_quota_fail_closed() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -183,6 +183,7 @@ fn canonicalize_history_entry(
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
extensions: Vec::new(),
},
}
}
+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));
}
}
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry(
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
&entry.item,
entry.metadata.entry_id.0.clone(),
*ts,
@@ -100,7 +100,10 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
}
}
SessionSnapshot { entries }
SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries,
}
}
fn extend_history(
+1
View File
@@ -287,6 +287,7 @@ pub fn append_system_item(
LogEntry::AnnotatedSystemItem {
ts: segment_log::now_millis(),
entry,
extensions: Vec::new(),
},
)
}
+10 -1
View File
@@ -112,6 +112,8 @@ pub enum LogEntry {
AnnotatedSystemItem {
ts: u64,
entry: LoggedSystemHistoryEntry,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
},
/// Turn boundary. Records the turn count after increment.
@@ -312,12 +314,19 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
LogEntry::AnnotatedSystemItem {
entry, extensions, ..
} => {
state.annotated_history.push(LoggedHistoryEntry {
item: LoggedItem::from(entry.item.to_history_item()),
metadata: entry.metadata.clone(),
});
state.history.push(entry.item.to_history_item());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count {
+41
View File
@@ -226,6 +226,47 @@ pub trait Store: Send + Sync {
Err(StoreError::PasteArtifactUnsupported)
}
/// Retain an uploaded file while a durable pending operation owns it.
fn pin_uploaded_file(
&self,
_session_id: SessionId,
_reference: &UploadedFileRef,
_owner_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Release a pending-operation pin without changing committed ownership.
fn release_uploaded_file_pin(
&self,
_session_id: SessionId,
_artifact_id: &str,
_owner_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Complete the pending-to-history handoff after the history entry commits.
fn finalize_uploaded_file_binding(
&self,
_session_id: SessionId,
_artifact_id: &str,
_source_entry_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Clear pending-operation pins that have no owner in restored durable
/// Worker Session state. This repairs an interrupted pin-before-checkpoint
/// acceptance without disturbing live queue owners or committed history.
fn reconcile_uploaded_file_pins(
&self,
_session_id: SessionId,
_live_owner_ids: &[String],
) -> Result<u64, StoreError> {
Ok(0)
}
/// Delete an uncommitted uploaded file owned by `session_id`.
fn delete_uploaded_file(
&self,
+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;
const MAX_FILE_NAME_CHARS: usize = 255;
const MAX_MEDIA_TYPE_BYTES: usize = 127;
fn validate_pending_owner_id(owner_id: &str) -> Result<()> {
if owner_id.is_empty() || owner_id.len() > 256 {
return Err(StoreError::ArtifactIntegrityMismatch);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UploadedFileLimits {
@@ -59,6 +65,8 @@ struct StoredUploadedFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
source_entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pending_owner_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
upload_context: Option<UploadedFileUploadContext>,
content_base64: String,
}
@@ -291,6 +299,7 @@ pub(crate) fn write_uploaded_file(
byte_len,
sha256: sha256.clone(),
source_entry_id: None,
pending_owner_id: None,
upload_context: context.cloned(),
content_base64: BASE64.encode(content),
};
@@ -338,6 +347,12 @@ pub(crate) fn read_uploaded_file_by_id(
Ok((reference, content))
}
pub(crate) fn uploaded_file_has_pending_owner(dir: &Path, artifact_id: &str) -> Result<bool> {
let path = record_path(dir, artifact_id)?;
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(path)?)?;
Ok(stored.pending_owner_id.is_some())
}
pub(crate) fn read_uploaded_file(dir: &Path, reference: &UploadedFileRef) -> Result<Vec<u8>> {
let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
if stored_reference.file_name != reference.file_name
@@ -376,6 +391,98 @@ pub(crate) fn clear_uploaded_file_binding(
Ok(())
}
pub(crate) fn pin_uploaded_file(
dir: &Path,
reference: &UploadedFileRef,
owner_id: &str,
) -> Result<()> {
validate_pending_owner_id(owner_id)?;
if reference.source_entry_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, &reference.artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.file_name != reference.file_name
|| stored.media_type != reference.media_type
|| stored.created_at_ms != reference.created_at_ms
|| stored.byte_len != reference.byte_len
|| stored.sha256 != reference.sha256
{
return Err(StoreError::ArtifactIntegrityMismatch);
}
if stored.source_entry_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
if let Some(existing_owner) = stored.pending_owner_id.as_deref() {
return if existing_owner == owner_id {
Ok(())
} else {
Err(StoreError::ArtifactAlreadyCommitted)
};
}
stored.pending_owner_id = Some(owner_id.to_owned());
let temp = dir.join(format!(".{}.file.pin.tmp", reference.artifact_id));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn release_uploaded_file_pin(
dir: &Path,
artifact_id: &str,
owner_id: &str,
) -> Result<()> {
validate_pending_owner_id(owner_id)?;
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.pending_owner_id.as_deref() != Some(owner_id) {
return Err(StoreError::ArtifactIntegrityMismatch);
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.unpin.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn finalize_uploaded_file_binding(
dir: &Path,
artifact_id: &str,
source_entry_id: &str,
) -> Result<()> {
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.source_entry_id.as_deref() != Some(source_entry_id) {
return Err(StoreError::ArtifactIntegrityMismatch);
}
if stored.pending_owner_id.is_none() {
return Ok(());
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.finalize.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn bind_uploaded_file(
dir: &Path,
reference: &UploadedFileRef,
@@ -479,6 +586,40 @@ pub(crate) fn copy_committed_uploaded_files(source_dir: &Path, target_dir: &Path
Ok(copied)
}
pub(crate) fn reconcile_uploaded_file_pins(dir: &Path, live_owner_ids: &[String]) -> Result<u64> {
fs::create_dir_all(dir)?;
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let mut reconciled = 0_u64;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(artifact_id) = file_name.strip_suffix(".file.json") else {
continue;
};
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
let Some(owner_id) = stored.pending_owner_id.as_deref() else {
continue;
};
if live_owner_ids.iter().any(|live| live == owner_id) {
continue;
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.reconcile.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
reconciled = reconciled.saturating_add(1);
}
Ok(reconciled)
}
pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
fs::create_dir_all(dir)?;
let aggregate_lock = fs::OpenOptions::new()
@@ -499,7 +640,7 @@ pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
continue;
}
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.source_entry_id.is_none() {
if stored.source_entry_id.is_none() && stored.pending_owner_id.is_none() {
fs::remove_file(path)?;
removed = removed
.checked_add(1)
@@ -523,7 +664,7 @@ pub(crate) fn delete_uploaded_file(dir: &Path, artifact_id: &str) -> Result<bool
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if stored.source_entry_id.is_some() {
if stored.source_entry_id.is_some() || stored.pending_owner_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
match fs::remove_file(path) {
+375 -8
View File
@@ -14,8 +14,24 @@
use crate::{SegmentId, SessionId};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, Weak};
fn metadata_lock(path: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
let mut locks = LOCKS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.expect("metadata lock registry poisoned");
if let Some(lock) = locks.get(path).and_then(Weak::upgrade) {
return lock;
}
let lock = Arc::new(Mutex::new(()));
locks.insert(path.to_path_buf(), Arc::downgrade(&lock));
lock
}
/// Errors from Worker metadata persistence.
#[derive(Debug, thiserror::Error)]
@@ -63,6 +79,8 @@ pub struct WorkerSpawnedScopeRule {
pub target: PathBuf,
pub permission: String,
pub recursive: bool,
#[serde(default)]
pub symlink_policy: protocol::SymlinkPolicy,
}
/// One child Worker spawned by this Worker and persisted with the spawner's
@@ -180,6 +198,24 @@ pub trait WorkerMetadataStore: Send + Sync {
Ok(metadata)
}
/// Compare and swap the active Segment pointer while preserving unrelated metadata.
/// Returns `false` without mutation when the durable pointer no longer matches.
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let mut matched = false;
self.update_by_name(worker_name, |metadata| {
if metadata.active.as_ref() == Some(expected) {
metadata.active = Some(replacement);
matched = true;
}
})?;
Ok(matched)
}
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
fn set_active(
&self,
@@ -328,6 +364,7 @@ pub trait WorkerMetadataStore: Send + Sync {
pub struct WorkerAggregateStore {
root: PathBuf,
worker_name: String,
update_lock: Arc<Mutex<()>>,
}
impl WorkerAggregateStore {
@@ -339,7 +376,11 @@ impl WorkerAggregateStore {
let worker_name = worker_name.into();
validate_worker_name(&worker_name)?;
fs::create_dir_all(&root)?;
Ok(Self { root, worker_name })
Ok(Self {
update_lock: metadata_lock(&root),
root,
worker_name,
})
}
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
@@ -406,6 +447,47 @@ impl WorkerMetadataStore for WorkerAggregateStore {
Ok(Some(metadata))
}
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let mut metadata = self
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
update(&mut metadata);
self.write(&metadata)?;
Ok(metadata)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let Some(mut metadata) = self.read_by_name(worker_name)? else {
return Ok(false);
};
if metadata.active.as_ref() != Some(expected) {
return Ok(false);
}
metadata.active = Some(replacement);
self.write(&metadata)?;
Ok(true)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
Ok(if self.metadata_path().is_file() {
vec![self.worker_name.clone()]
@@ -432,6 +514,7 @@ impl WorkerMetadataStore for WorkerAggregateStore {
#[derive(Clone)]
pub struct FsWorkerStore {
root: PathBuf,
update_lock: Arc<Mutex<()>>,
}
impl FsWorkerStore {
@@ -439,7 +522,10 @@ impl FsWorkerStore {
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
let root = root.into();
fs::create_dir_all(&root)?;
Ok(Self { root })
Ok(Self {
update_lock: metadata_lock(&root),
root,
})
}
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
@@ -455,12 +541,32 @@ impl FsWorkerStore {
impl WorkerMetadataStore for FsWorkerStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
let path = self.metadata_path(&metadata.worker_name)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
let mut content = serde_json::to_vec_pretty(metadata)?;
content.push(b'\n');
let parent = path.parent().expect("metadata path has parent");
fs::create_dir_all(parent)?;
let temp = parent.join(format!(
".metadata.json.tmp-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
let result = (|| -> Result<(), WorkerStoreError> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&content)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
}
let content = serde_json::to_vec_pretty(metadata)?;
fs::write(path, content)?;
Ok(())
result
}
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
@@ -473,6 +579,47 @@ impl WorkerMetadataStore for FsWorkerStore {
Ok(Some(serde_json::from_str(&content)?))
}
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let mut metadata = self
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
update(&mut metadata);
self.write(&metadata)?;
Ok(metadata)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let Some(mut metadata) = self.read_by_name(worker_name)? else {
return Ok(false);
};
if metadata.active.as_ref() != Some(expected) {
return Ok(false);
}
metadata.active = Some(replacement);
self.write(&metadata)?;
Ok(true)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
let mut names = Vec::new();
if !self.root.exists() {
@@ -648,6 +795,26 @@ where
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.worker_metadata_store.read_by_name(worker_name)
}
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
self.worker_metadata_store
.update_by_name(worker_name, update)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
self.worker_metadata_store
.compare_and_swap_active(worker_name, expected, replacement)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
self.worker_metadata_store.list_names()
}
@@ -662,6 +829,7 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::{LogEntry, Store};
#[test]
fn worker_metadata_manifest_snapshot_roundtrips() {
@@ -682,6 +850,25 @@ mod tests {
assert_eq!(restored, metadata);
}
#[test]
fn spawned_scope_rule_defaults_resolved_and_roundtrips_logical_policy() {
let legacy: WorkerSpawnedScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace/src",
"permission": "read",
"recursive": true
}))
.unwrap();
assert_eq!(legacy.symlink_policy, protocol::SymlinkPolicy::Resolved);
let logical = WorkerSpawnedScopeRule {
symlink_policy: protocol::SymlinkPolicy::Logical,
..legacy
};
let restored: WorkerSpawnedScopeRule =
serde_json::from_value(serde_json::to_value(&logical).unwrap()).unwrap();
assert_eq!(restored, logical);
}
#[test]
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
let tmp = tempfile::tempdir().unwrap();
@@ -835,6 +1022,7 @@ mod tests {
target: std::path::Path::new("/tmp/delegated").into(),
permission: "write".into(),
recursive: true,
symlink_policy: Default::default(),
};
store
.set_spawned_children(
@@ -862,4 +1050,183 @@ mod tests {
assert_eq!(restored.reclaimed_children.len(), 1);
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
}
#[test]
fn staged_segment_is_invisible_until_cas_and_reopen_selects_committed_history() {
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let workers = temp.path().join("workers");
let open = || {
CombinedStore::new(
crate::FsStore::new(&sessions).unwrap(),
FsWorkerStore::new(&workers).unwrap(),
)
};
let store = open();
let session_id = crate::new_session_id();
let old_segment_id = crate::new_segment_id();
let new_segment_id = crate::new_segment_id();
let entry = |label: &str| LogEntry::Extension {
ts: 1,
domain: label.into(),
payload: serde_json::json!({}),
};
store
.create_segment(session_id, old_segment_id, &[entry("old-history")])
.unwrap();
store
.write(&WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::active_segment(
session_id,
old_segment_id,
)),
))
.unwrap();
store
.create_segment(session_id, new_segment_id, &[entry("new-history")])
.unwrap();
drop(store);
let reopened = open();
assert_eq!(
reopened
.read_by_name("agent")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(old_segment_id)
);
assert!(
reopened
.compare_and_swap_active(
"agent",
&WorkerActiveSegmentRef::active_segment(session_id, old_segment_id),
WorkerActiveSegmentRef::active_segment(session_id, new_segment_id),
)
.unwrap()
);
drop(reopened);
let reopened = open();
assert_eq!(
reopened
.read_by_name("agent")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(new_segment_id)
);
assert!(matches!(
reopened.read_all(session_id, new_segment_id).unwrap().as_slice(),
[LogEntry::Extension { domain, .. }] if domain == "new-history"
));
}
#[test]
fn aggregate_store_uses_expected_old_segment_cas() {
let temp = tempfile::tempdir().unwrap();
let store = WorkerAggregateStore::new(temp.path(), "agent").unwrap();
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
assert!(
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
)
.unwrap()
);
assert!(
!store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
)
.unwrap()
);
}
#[test]
fn combined_store_delegates_atomic_active_segment_cas() {
let temp = tempfile::tempdir().unwrap();
let metadata = FsWorkerStore::new(temp.path().join("workers")).unwrap();
let store = CombinedStore::new(
crate::FsStore::new(temp.path().join("sessions")).unwrap(),
metadata,
);
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
let store = store.clone();
let old = old.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
)
.unwrap()
})
});
barrier.wait();
assert_eq!(
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|won| *won)
.count(),
1
);
}
#[test]
fn active_segment_cas_allows_exactly_one_concurrent_winner() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
let store = store.clone();
let old = old.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
)
.unwrap()
})
});
barrier.wait();
let winners = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|won| *won)
.count();
assert_eq!(winners, 1);
}
}
+9 -5
View File
@@ -3,7 +3,7 @@ mod common;
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
use agen::interceptor::{Interceptor, TurnEndAction};
use agen::interceptor::{AssistantTurnEndContext, Interceptor, InterceptorResult, TurnEndAction};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::types::{Item, RequestConfig};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
@@ -100,8 +100,11 @@ struct PausePolicy;
#[async_trait]
impl Interceptor for PausePolicy {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
TurnEndAction::Pause
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Pause)
}
}
@@ -350,7 +353,8 @@ async fn session_run_with_tool_call() {
async fn session_resume_after_pause() {
let (_dir, store) = make_store();
// First run: tool call with pause policy → Paused
// First terminal assistant response requests a tool; the assistant-turn
// interceptor pauses before the Engine enters the tool phase.
let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition());
@@ -386,7 +390,7 @@ async fn session_resume_after_pause() {
// Restore state and verify
let state = session_store::restore(&store, sid, segid).unwrap();
assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, Some(2));
assert_eq!(state.active_run_turn_count, Some(1));
}
#[tokio::test]
+10 -2
View File
@@ -318,7 +318,11 @@ impl StandaloneHost {
}
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
let _ = self.handle.send(Method::Shutdown).await;
let command = protocol::WorkerCommandEnvelope::for_snapshot(
u64::MAX,
&self.handle.shared_state.snapshot(),
);
let _ = self.handle.send(Method::Shutdown { command }).await;
let Some(shutdown) = self.shutdown.take() else {
self.retain_lease();
return Err(StandaloneShutdownError::ConfirmationLost);
@@ -500,7 +504,11 @@ fn active_pointer(
}
async fn stop_started_worker(started: BootstrappedWorker) {
let _ = started.handle.send(Method::Shutdown).await;
let command = protocol::WorkerCommandEnvelope::for_snapshot(
u64::MAX,
&started.handle.shared_state.snapshot(),
);
let _ = started.handle.send(Method::Shutdown { command }).await;
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
}
+111 -6
View File
@@ -191,8 +191,7 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error)
}
})?;
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
let record = decode_worker_record(id, &bytes)?;
if record.schema_version > SCHEMA_VERSION {
return Err(StandaloneStoreError::NewerSchema {
id,
@@ -408,7 +407,7 @@ impl StandaloneWorkerStore {
.create_new(true)
.open(&temporary)
.map_err(StandaloneStoreError::Io)?;
serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?;
write_worker_record(&mut file, next)?;
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?;
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
@@ -428,8 +427,7 @@ impl StandaloneWorkerStore {
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let bytes =
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
decode_worker_record(id, &bytes)
}
fn worker_dir(&self, id: WorkerId) -> PathBuf {
@@ -634,6 +632,50 @@ fn observe_process(pid: u32) -> ProcessObservation {
}
}
fn decode_worker_record(
id: WorkerId,
bytes: &[u8],
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let decode = || -> Result<StandaloneWorkerRecord, serde_json::Error> {
let mut snapshot: serde_json::Value = serde_json::from_slice(bytes)?;
let object = snapshot.as_object_mut().ok_or_else(|| {
serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record must be an object",
))
})?;
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record is missing manifest",
))
})?;
let manifest = manifest::read_persisted_worker_manifest_snapshot(persisted_manifest)?;
object.insert("manifest".to_string(), serde_json::to_value(manifest)?);
serde_json::from_value(snapshot)
};
decode().map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
}
fn write_worker_record(
writer: &mut impl Write,
record: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> {
let mut snapshot = serde_json::to_value(record).map_err(StandaloneStoreError::Json)?;
let object = snapshot.as_object_mut().ok_or_else(|| {
StandaloneStoreError::Json(serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record must be an object",
)))
})?;
object.insert(
"manifest".to_string(),
manifest::write_persisted_worker_manifest_snapshot(&record.manifest)
.map_err(StandaloneStoreError::Json)?,
);
serde_json::to_writer_pretty(writer, &snapshot).map_err(StandaloneStoreError::Json)
}
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -709,7 +751,70 @@ pub enum StandaloneStoreError {
#[cfg(test)]
mod tests {
use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness};
use super::*;
fn test_manifest() -> WorkerManifest {
WorkerManifest::from_toml(
r#"
[worker]
name = "standalone-test"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[engine]
[[scope.allow]]
target = "/tmp"
permission = "write"
"#,
)
.unwrap()
}
#[test]
fn standalone_record_uses_versioned_manifest_adapter_for_legacy_memory() {
let worker_id = "01a05782-d5dd-78f1-b9cd-ce37535bdb9d".parse().unwrap();
let manifest = test_manifest();
let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION,
revision: 6,
worker_id,
worker_name: manifest.worker.name.clone(),
storage_key: "standalone-test".to_string(),
cwd: StandaloneCwdIdentity {
canonical_path: PathBuf::from("/tmp"),
device: None,
inode: None,
},
manifest,
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
active_segment_id: None,
status: StandaloneWorkerStatus::Stopped,
created_at_unix_ms: 1,
updated_at_unix_ms: 2,
shutdown_reason: None,
};
let mut legacy = serde_json::to_value(&record).unwrap();
legacy["manifest"]["feature"]["memory"] = serde_json::json!({
"enabled": false,
"staging": false,
});
let decoded =
decode_worker_record(worker_id, &serde_json::to_vec(&legacy).unwrap()).unwrap();
assert!(!decoded.manifest.feature.memory.profile.enabled);
let mut persisted = Vec::new();
write_worker_record(&mut persisted, &decoded).unwrap();
let persisted: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
assert_eq!(persisted["manifest"]["schema_version"], 2);
assert_eq!(
persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"],
false
);
}
#[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() {
+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();
protocol_client
.send(&Method::run_text("read the probe"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"read the probe",
))
.await
.expect("submit input");
@@ -336,11 +339,15 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
let worker_id = host.worker_id();
let mut protocol_client = host.connect();
protocol_client
.send(&Method::run_text("first request"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"first request",
))
.await?;
wait_for_run_end(&mut protocol_client).await?;
protocol_client
.send(&Method::Notify {
notification_request_id: protocol::new_submission_request_id(),
message: "persisted notification".to_string(),
auto_run: true,
})
@@ -394,7 +401,10 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
assert!(snapshot.contains("persisted notification"), "{snapshot}");
protocol_client
.send(&Method::run_text("continue after restore"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"continue after restore",
))
.await?;
wait_for_run_end(&mut protocol_client).await?;
let request = second_inspection
+3
View File
@@ -118,6 +118,7 @@ impl Tool for BashTool {
command: params.command,
timeout_secs,
output_limit: INLINE_BYTE_BUDGET,
cwd: None,
spill_dir: Some(self.output_dir.clone()),
tool_call_id: Some(call_id.clone()),
})
@@ -299,11 +300,13 @@ mod tests {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
},
ScopeRule {
target: output.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
},
],
deny: Vec::new(),
+1
View File
@@ -40,6 +40,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
target: spill.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
});
let scope = Scope::from_config(&config).unwrap();
let fs: WorkdirSessionHandle =
+1
View File
@@ -27,6 +27,7 @@ fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
target: spill.to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
});
Scope::from_config(&config).unwrap()
}
+402 -198
View File
File diff suppressed because it is too large Load Diff
+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());
}
}
+269 -41
View File
@@ -3,8 +3,9 @@ use std::io;
use std::time::Duration;
use client::{
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers,
list_backend_workers, restore_backend_worker,
BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Frame;
@@ -12,6 +13,7 @@ use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;
use crate::backend_workspace_picker::select_backend_workspace;
use crate::console;
@@ -83,17 +85,20 @@ pub(crate) async fn run(
let restore_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?;
restore_backend_worker(&restore_target)
let restore = restore_backend_worker(&restore_target)
.await
.map_err(|error| {
io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
.result
.worker
.unwrap_or(selected)
})?;
restored_worker(restore).map_err(|error| {
io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
} else {
selected
};
@@ -104,6 +109,33 @@ pub(crate) async fn run(
}
}
fn restored_worker(response: BackendWorkerRestoreResponse) -> Result<BackendWorkerSummary, String> {
if response.result.state != BackendWorkerOperationState::Accepted {
let diagnostics = response
.result
.diagnostics
.iter()
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
.collect::<Vec<_>>()
.join("; ");
let state = match response.result.state {
BackendWorkerOperationState::Accepted => unreachable!(),
BackendWorkerOperationState::Rejected => "rejected",
BackendWorkerOperationState::Unsupported => "unsupported",
};
return Err(if diagnostics.is_empty() {
format!("restore was {state} without a diagnostic")
} else {
format!("restore was {state}: {diagnostics}")
});
}
response
.result
.worker
.ok_or_else(|| "restore was accepted without a Worker snapshot".to_string())
}
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
let mut seen = std::collections::HashSet::new();
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
@@ -235,9 +267,10 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
layout[0],
);
let column_widths = WorkerColumnWidths::from_workers(&state.workers);
for (i, worker) in state.workers.iter().enumerate() {
frame.render_widget(
Paragraph::new(row_line(worker, i == state.selected)),
Paragraph::new(row_line(worker, &column_widths, i == state.selected)),
layout[i + 1],
);
}
@@ -272,7 +305,28 @@ fn picker_title(target: &BackendRuntimeListTarget) -> String {
format!("backend workers workspace: {workspace} runtime: {runtime}")
}
fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct WorkerColumnWidths {
identity: usize,
name: usize,
state: usize,
}
impl WorkerColumnWidths {
fn from_workers(workers: &[BackendWorkerSummary]) -> Self {
workers.iter().fold(Self::default(), |widths, worker| Self {
identity: widths.identity.max(text_width(&short_worker_id(worker))),
name: widths.name.max(text_width(worker_name(worker))),
state: widths.state.max(text_width(&worker_state(worker))),
})
}
}
fn row_line(
worker: &BackendWorkerSummary,
widths: &WorkerColumnWidths,
selected: bool,
) -> Line<'static> {
let marker = if selected { "" } else { " " };
let id_style = if selected {
Style::default()
@@ -281,42 +335,70 @@ fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
} else {
Style::default().fg(Color::Cyan)
};
let preview_style = if selected {
let name_style = if selected {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let label = if worker.label.is_empty() {
worker.worker_id.as_str()
} else {
worker.label.as_str()
};
let profile = worker.profile.as_deref().unwrap_or("-");
Line::from(vec![
Span::raw(marker),
Span::styled(short_worker_id(worker), id_style),
Span::raw(" "),
Span::styled(
format!("[{}]", worker.state),
state_style(worker.state.as_str()),
pad_column(&short_worker_id(worker), widths.identity),
id_style,
),
Span::raw(" "),
Span::styled(pad_column(worker_name(worker), widths.name), name_style),
Span::raw(" "),
Span::styled(
format!("profile:{profile}"),
Style::default().fg(Color::DarkGray),
pad_column(&worker_state(worker), widths.state),
state_style(worker_state_label(worker)),
),
Span::raw(" "),
Span::styled(
working_directory_text(worker),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(label.to_string(), preview_style),
])
}
fn worker_name(worker: &BackendWorkerSummary) -> &str {
if !worker.label.is_empty() {
worker.label.as_str()
} else if !worker.display_name.is_empty() {
worker.display_name.as_str()
} else {
worker.worker_id.as_str()
}
}
fn worker_state_label(worker: &BackendWorkerSummary) -> &str {
match worker.worker_state.as_ref().map(|state| &state.state) {
Some(protocol::WorkerState::Idle) => "idle",
Some(protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Paused,
))) => "paused",
Some(protocol::WorkerState::Busy(_)) => "running",
None if worker.state == "stopped" => "stopped",
None => "unknown",
}
}
fn worker_state(worker: &BackendWorkerSummary) -> String {
format!("[{}]", worker_state_label(worker))
}
fn text_width(value: &str) -> usize {
UnicodeWidthStr::width(value)
}
fn pad_column(value: &str, width: usize) -> String {
format!(
"{value}{}",
" ".repeat(width.saturating_sub(text_width(value)))
)
}
fn state_style(state: &str) -> Style {
match state {
"running" | "idle" | "active" => Style::default()
@@ -347,18 +429,15 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
let Some(wd) = worker.working_directory.as_ref() else {
return "wd:—".to_string();
};
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
format!(
"wd:{}:{} {} {}",
wd.repository_key, wd.working_directory_id, wd.status, cleanliness
)
format!("wd:{}{}", wd.repository_key, wd.working_directory_id)
}
#[cfg(test)]
mod tests {
use super::*;
use client::{
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
BackendWorkerWorkspaceSummary,
};
@@ -378,7 +457,15 @@ mod tests {
identity: "ws".to_string(),
workspace_id: Some("ws".to_string()),
},
state: "running".to_string(),
state: "idle".to_string(),
worker_state: Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 1,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 0,
}),
last_seen_at: None,
pinned: false,
retention_state: String::new(),
@@ -395,18 +482,159 @@ mod tests {
}
}
#[test]
fn worker_row_matches_inline_picker_shape() {
let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true);
let text = row
fn row_text(worker: &BackendWorkerSummary, widths: &WorkerColumnWidths) -> String {
row_line(worker, widths, false)
.spans
.into_iter()
.map(|span| span.content)
.collect::<String>();
assert!(text.starts_with("▶ W-1"));
assert!(text.contains("[running]"));
assert!(text.contains("profile:default"));
assert!(text.contains("wd:—"));
.collect()
}
fn display_column(text: &str, value: &str) -> usize {
let byte_offset = text.find(value).expect("value in rendered row");
text_width(&text[..byte_offset])
}
fn restore_response(
state: BackendWorkerOperationState,
worker: Option<BackendWorkerSummary>,
diagnostics: Vec<BackendDiagnostic>,
) -> BackendWorkerRestoreResponse {
BackendWorkerRestoreResponse {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
worker_id: "worker-a".to_string(),
result: BackendWorkerRestoreResult {
state,
worker,
diagnostics,
},
}
}
#[test]
fn rejected_restore_surfaces_diagnostic_instead_of_attaching_selected_worker() {
let error = restored_worker(restore_response(
BackendWorkerOperationState::Rejected,
None,
vec![BackendDiagnostic {
code: "working_directory_not_found".to_string(),
severity: BackendDiagnosticSeverity::Error,
message: "working directory was not found".to_string(),
}],
))
.expect_err("rejected restore must not produce a Worker to attach");
assert_eq!(
error,
"restore was rejected: working_directory_not_found: working directory was not found"
);
}
#[test]
fn accepted_restore_requires_returned_worker_snapshot() {
let error = restored_worker(restore_response(
BackendWorkerOperationState::Accepted,
None,
Vec::new(),
))
.expect_err("accepted restore without a Worker must not attach the stale selection");
assert_eq!(error, "restore was accepted without a Worker snapshot");
}
#[test]
fn accepted_restore_returns_authoritative_worker_snapshot() {
let worker = worker("runtime-a", "worker-a", Some("builtin:companion"));
let restored = restored_worker(restore_response(
BackendWorkerOperationState::Accepted,
Some(worker.clone()),
Vec::new(),
))
.expect("accepted restore should return its Worker snapshot");
assert_eq!(restored, worker);
}
#[test]
fn worker_row_orders_and_simplifies_columns() {
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
worker.resource_key = "W-90".to_string();
worker.display_name = "Coder".to_string();
worker.label = "Coder · T-585".to_string();
worker.state = "stopped".to_string();
worker.worker_state = None;
worker.working_directory = Some(
serde_json::from_value(serde_json::json!({
"working_directory_id": "001a06a9f0202000000",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active",
"cleanliness": "clean"
}))
.unwrap(),
);
let widths = WorkerColumnWidths::from_workers(std::slice::from_ref(&worker));
let text = row_text(&worker, &widths);
assert_eq!(
text,
" W-90 Coder · T-585 [stopped] wd:main・001a06a9f0202000000"
);
assert!(!text.contains("profile:"));
assert!(!text.contains("active clean"));
}
#[test]
fn worker_rows_align_identity_name_state_and_workdir_columns() {
let mut short = worker("runtime-a", "worker-a", None);
short.resource_key = "W-2".to_string();
short.label = "Coder".to_string();
short.display_name = short.label.clone();
short.state = "idle".to_string();
short.worker_state = Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 2,
state: protocol::WorkerState::Idle,
last_command_id: 0,
});
let mut long = worker("runtime-a", "worker-b", None);
long.resource_key = "W-100".to_string();
long.label = "Longer worker · T-9".to_string();
long.display_name = long.label.clone();
long.state = "stopped".to_string();
long.worker_state = None;
for worker in [&mut short, &mut long] {
worker.working_directory = Some(
serde_json::from_value(serde_json::json!({
"working_directory_id": "workdir-1",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active"
}))
.unwrap(),
);
}
let workers = vec![short, long];
let widths = WorkerColumnWidths::from_workers(&workers);
let first = row_text(&workers[0], &widths);
let second = row_text(&workers[1], &widths);
assert_eq!(
display_column(&first, "Coder"),
display_column(&second, "Longer")
);
assert_eq!(
display_column(&first, "[idle]"),
display_column(&second, "[stopped]")
);
assert_eq!(
display_column(&first, "wd:main"),
display_column(&second, "wd:main")
);
}
#[test]
+5 -5
View File
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
println!("Repository path/URI is required.");
return Ok(None);
}
let repository_name = prompt_line("Repository display name [Main]: ")?;
let repository_key = prompt_line("Repository key [main]: ")?;
let default_ref = prompt_line("Default ref [repository default]: ")?;
let operation_key = format!(
"tui-workspace-create-{}-{}",
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
display_name,
repository: CreateBackendWorkspaceRepository {
uri,
display_name: Some(if repository_name.is_empty() {
"Main".to_string()
repository_key: if repository_key.is_empty() {
"main".to_string()
} else {
repository_name
}),
repository_key
},
default_ref: (!default_ref.is_empty()).then_some(default_ref),
},
}))
+7 -2
View File
@@ -409,7 +409,12 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution {
method: Some(Method::Compact),
method: Some(Method::Compact {
command: protocol::WorkerCommandEnvelope::for_snapshot(
0,
&protocol::WorkerStateSnapshot::initial(1),
),
}),
diagnostics: vec![CommandDiagnostic::new("compact requested")],
exit_command_mode: true,
clear_input: true,
@@ -483,7 +488,7 @@ mod tests {
fn compact_command_returns_compact_method_not_run() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("compact", &env());
assert!(matches!(result.method, Some(Method::Compact)));
assert!(matches!(result.method, Some(Method::Compact { .. })));
assert!(result.exit_command_mode);
assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("compact requested"));
+177 -128
View File
@@ -270,8 +270,8 @@ impl<T: Socket> ConsoleConnection<T> {
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
let mut prepared = method.clone();
let carries_attachments =
matches!(prepared, Method::Run { .. }) && !self.pending_attachments.is_empty();
if let Method::Run { input } = &mut prepared {
matches!(prepared, Method::Submit { .. }) && !self.pending_attachments.is_empty();
if let Method::Submit { input, .. } = &mut prepared {
input.extend(
self.pending_attachments
.iter()
@@ -569,9 +569,10 @@ async fn run_e2e_rewind_fixture(
app.connected = true;
app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
greeting: Greeting {
worker_name: worker_name.clone(),
cwd: workspace_root.display().to_string(),
@@ -697,6 +698,7 @@ async fn run_e2e_rewind_fixture(
if submitted_at.elapsed() >= apply_delay {
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
input: vec![Segment::text("rewind-live-refresh")],
@@ -916,7 +918,7 @@ async fn run_loop<T: Socket>(
}
fn attachment_command_path(method: &Method) -> Option<PathBuf> {
let Method::Run { input } = method else {
let Method::Submit { input, .. } = method else {
return None;
};
let [Segment::Text { content }] = input.as_slice() else {
@@ -927,7 +929,7 @@ fn attachment_command_path(method: &Method) -> Option<PathBuf> {
}
fn is_clear_attachments_command(method: &Method) -> bool {
let Method::Run { input } = method else {
let Method::Submit { input, .. } = method else {
return false;
};
matches!(
@@ -941,7 +943,7 @@ async fn send_console_method<T: Socket>(
client: &mut ConsoleConnection<T>,
method: &Method,
) -> Result<(), Box<dyn std::error::Error>> {
if matches!(method, Method::Run { .. }) && client.has_active_uploads() {
if matches!(method, Method::Submit { .. }) && client.has_active_uploads() {
app.restore_unsent_run(method);
app.flash_actionbar_notice(
"Attachment upload is still in progress; wait or use /clear-attachments.",
@@ -953,7 +955,7 @@ async fn send_console_method<T: Socket>(
}
let sends_attachments =
matches!(method, Method::Run { .. }) && !client.pending_attachments.is_empty();
matches!(method, Method::Submit { .. }) && !client.pending_attachments.is_empty();
if let Err(error) = client.send(method).await {
if sends_attachments {
app.restore_unsent_run(method);
@@ -1148,18 +1150,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.clear_command_input();
Some(None)
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'d') && alt && !ctrl && !app.is_command_mode() =>
{
Some(
app.next_queued_input_preview()
.map(str::to_owned)
.map(|submission_id| app.cancel_pending_method(submission_id)),
)
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'n') && alt && !ctrl && !app.is_command_mode() =>
{
Some(app.submit_notify_input())
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
{
if app.restore_next_queued_input_to_composer() {
Some(app.refresh_completion())
} else {
Some(None)
}
Some(app.continue_pending_method())
}
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
app.clear_queued_inputs();
Some(None)
Some(Some(app.clear_pending_method()))
}
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
@@ -1427,14 +1438,15 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
WorkerStatus::Running | WorkerStatus::Paused
) {
app.shutdown_confirm = None;
app.clear_queued_inputs();
return Some(Method::Cancel);
let command = app.next_command_envelope();
return Some(Method::Cancel { command });
}
if let Some(pressed_at) = app.shutdown_confirm
&& pressed_at.elapsed() < CONFIRM_TIMEOUT
{
app.shutdown_confirm = None;
return Some(Method::Shutdown);
let command = app.next_command_envelope();
return Some(Method::Shutdown { command });
}
app.shutdown_confirm = Some(std::time::Instant::now());
app.flash_actionbar_notice(
@@ -1450,8 +1462,8 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.worker_status == WorkerStatus::Running {
app.clear_queued_inputs();
return Some(Method::Pause);
let command = app.next_command_envelope();
return Some(Method::Pause { command });
}
if let Some(t) = app.quit_confirm
&& t.elapsed() < CONFIRM_TIMEOUT
@@ -1476,8 +1488,8 @@ mod tests {
use crate::text_selection::{HistoryViewport, SelectionRow};
use async_trait::async_trait;
use protocol::{
Event, RewindTarget, RewindTargetId, RunResult, Segment, UploadedFileAvailability,
UploadedFileRef, WorkerStatus,
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
WorkerStatus,
};
#[test]
@@ -1490,7 +1502,8 @@ mod tests {
#[test]
fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() {
let attach = Method::Run {
let attach = Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: vec![Segment::text("/attach /tmp/report.md")],
};
assert_eq!(
@@ -1499,7 +1512,8 @@ mod tests {
);
assert!(!is_clear_attachments_command(&attach));
let clear = Method::Run {
let clear = Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: vec![Segment::text("/clear-attachments")],
};
assert!(is_clear_attachments_command(&clear));
@@ -1605,7 +1619,7 @@ mod tests {
}
#[tokio::test]
async fn queued_attachment_send_failure_restores_draft_without_exiting_console() {
async fn running_attachment_submit_failure_restores_draft_without_exiting_console() {
let file = UploadedFileRef {
artifact_id: "artifact-queued".into(),
file_name: "queued.txt".into(),
@@ -1631,13 +1645,10 @@ mod tests {
let mut app = App::new("worker".into());
app.set_worker_status(WorkerStatus::Running);
app.input.insert_str("queued inspect");
assert!(app.submit_input().is_none());
let method = app
.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
})
.expect("queued run must be released");
.submit_input()
.expect("running Submit is sent immediately");
send_console_method(&mut app, &mut connection, &method)
.await
.unwrap();
@@ -1960,7 +1971,7 @@ mod tests {
}
#[test]
fn running_enter_queues_instead_of_sending_run() {
fn running_enter_sends_submit_to_worker() {
let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() {
@@ -1973,102 +1984,128 @@ mod tests {
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!(matches!(
handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(Method::Submit { .. })
));
assert_eq!(app.queued_input_count(), 1);
assert_eq!(app.next_queued_input_preview(), Some("queued"));
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "");
}
#[test]
fn queued_input_keybindings_restore_and_clear() {
let mut app = App::new("agent".to_string());
fn running_alt_n_sends_explicit_notify_without_implicit_submit_conversion() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
for c in "edit queued".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
for character in "progress".chars() {
app.insert_char(character);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
)
.is_none()
let method = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::ALT),
);
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "edit queued");
app.input.clear();
for c in "clear queued".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert_eq!(app.queued_input_count(), 1);
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT)
)
.is_none()
);
assert_eq!(app.queued_input_count(), 0);
assert!(matches!(
method,
Some(Method::Notify {
ref message,
auto_run: true,
..
}) if message == "progress"
));
assert_eq!(input_text(&app), "");
}
#[test]
fn pause_and_cancel_clear_queued_input() {
let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert_eq!(app.queued_input_count(), 1);
fn pending_queue_shortcuts_send_worker_operations() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 6,
}],
},
});
let pause = handle_key(
let continue_next = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT),
);
assert!(matches!(pause, Some(Method::Pause)));
assert_eq!(app.queued_input_count(), 0);
for c in "queued again".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!(matches!(
continue_next,
Some(Method::ContinuePending {
expected_revision: 2,
ref expected_head_id,
}) if expected_head_id == "submission-1"
));
assert_eq!(app.queued_input_count(), 1);
let cancel = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT),
);
assert!(matches!(cancel, Some(Method::Cancel)));
assert_eq!(app.queued_input_count(), 0);
assert!(matches!(
cancel,
Some(Method::CancelPendingSubmission {
expected_revision: 2,
ref submission_id,
}) if submission_id == "submission-1"
));
let clear = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT),
);
assert!(matches!(
clear,
Some(Method::ClearPendingSubmissions {
expected_revision: 2
})
));
assert_eq!(app.queued_input_count(), 1);
}
#[test]
fn pause_and_cancel_preserve_authoritative_pending_queue() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 6,
}],
},
});
app.set_worker_status(WorkerStatus::Running);
assert!(matches!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
),
Some(Method::Pause { .. })
));
assert_eq!(app.queued_input_count(), 1);
app.set_worker_status(WorkerStatus::Running);
assert!(matches!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
),
Some(Method::Cancel { .. })
));
assert_eq!(app.queued_input_count(), 1);
}
#[test]
@@ -2080,7 +2117,7 @@ mod tests {
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(matches!(cancel, Some(Method::Cancel)));
assert!(matches!(cancel, Some(Method::Cancel { .. })));
}
#[test]
@@ -2102,7 +2139,7 @@ mod tests {
assert!(matches!(
handle_key(&mut app, ctrl_x()),
Some(Method::Shutdown)
Some(Method::Shutdown { .. })
));
assert!(app.shutdown_confirm.is_none());
}
@@ -2432,7 +2469,7 @@ mod tests {
}
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(method, Some(protocol::Method::Compact)));
assert!(matches!(method, Some(protocol::Method::Compact { .. })));
assert!(!app.is_command_mode());
assert_eq!(input_text(&app), "");
assert_eq!(app.queued_input_count(), 0);
@@ -2535,13 +2572,19 @@ mod tests {
let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::Text {
content: "retry this".into(),
}],
@@ -2562,15 +2605,21 @@ mod tests {
let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
type_keys(&mut app, "draft");
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::Text {
content: "retry this".into(),
}],
@@ -2697,8 +2746,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
enter_command_mode(&mut app);
@@ -2813,8 +2862,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
@@ -2839,8 +2888,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
handle_key(&mut app, key(KeyCode::Tab));
@@ -2856,7 +2905,7 @@ mod tests {
);
assert!(first.is_none());
assert!(matches!(second, Some(Method::Shutdown)));
assert!(matches!(second, Some(Method::Shutdown { .. })));
assert_eq!(app.worker_status, WorkerStatus::Idle);
}
@@ -2878,8 +2927,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
@@ -2918,12 +2967,12 @@ mod tests {
type_keys(&mut app, "first");
assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. })
Some(Method::Submit { .. })
));
type_keys(&mut app, "second");
assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. })
Some(Method::Submit { .. })
));
assert_eq!(input_text(&app), "");
@@ -2954,7 +3003,7 @@ mod tests {
type_keys(&mut app, "sent");
assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. })
Some(Method::Submit { .. })
));
type_keys(&mut app, "draft\nbody");
app.move_cursor_start();
+7
View File
@@ -1,5 +1,6 @@
mod app;
mod backend_dashboard;
mod backend_spawn;
mod backend_worker_picker;
mod backend_workspace_picker;
mod block;
@@ -51,6 +52,8 @@ pub enum LaunchMode {
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// `include_all` opts into all standalone Workers under the same client data root.
StandaloneResume { include_all: bool },
/// Create one Backend Worker and attach to it.
BackendSpawn,
/// List Backend Workers and attach to the selected Worker.
Workers {
runtime_id: Option<String>,
@@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}
}
LaunchMode::BackendSpawn => match target.launch_backend_worker() {
Ok(launch) => backend_spawn::run(launch.target).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Workers {
runtime_id,
include_stopped,
+1 -6
View File
@@ -228,7 +228,7 @@ worker_context_max_tokens = 100000
enabled = true
[feature.memory]
enabled = true
enabled = false
[feature.web]
enabled = true
@@ -241,11 +241,6 @@ enabled = true
authoring = true
thread = true
[memory]
extract_threshold = 50000
consolidation_threshold_files = 5
consolidation_threshold_bytes = 50000
[web]
enabled = true
+50 -20
View File
@@ -139,10 +139,17 @@ fn draw_run_status(frame: &mut Frame, app: &App, area: Rect) {
}
fn run_status_line(app: &App, now: Instant) -> Line<'static> {
let elapsed = app
.run_started_at
.and_then(|started_at| now.checked_duration_since(started_at))
.unwrap_or_default();
let elapsed = if let Some(progress) = &app.compaction_progress {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
std::time::Duration::from_millis(now_ms.saturating_sub(progress.started_at_ms))
} else {
app.run_started_at
.and_then(|started_at| now.checked_duration_since(started_at))
.unwrap_or_default()
};
let spinner_index =
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
let request_label = if app.run_requests == 1 {
@@ -151,7 +158,7 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
format!("{} reqs", app.run_requests)
};
Line::from(vec![
let mut spans = vec![
Span::styled(
RUN_SPINNER_FRAMES[spinner_index],
Style::default()
@@ -159,6 +166,20 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
if let Some(progress) = &app.compaction_progress {
let phase = match progress.phase {
protocol::CompactionPhase::Preparing => "preparing",
protocol::CompactionPhase::Summarizing => "summarizing",
protocol::CompactionPhase::Committing => "committing",
};
spans.push(Span::styled(
format!("Compacting · {phase}"),
Style::default().fg(Color::Cyan),
));
spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
}
spans.extend([
Span::styled(
fmt_run_elapsed(elapsed.as_secs()),
Style::default().fg(Color::Gray),
@@ -177,7 +198,8 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
fmt_tokens(app.run_output_tokens),
Style::default().fg(Color::Yellow),
),
])
]);
Line::from(spans)
}
fn fmt_run_elapsed(secs: u64) -> String {
@@ -1880,7 +1902,7 @@ fn actionbar_left_item(app: &App, now: Instant) -> Option<(String, Style)> {
}
if app.queued_input_count() > 0 {
return Some((
"Alt-q edit queued Alt-c clear queued".to_string(),
"Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".to_string(),
Style::default().fg(Color::DarkGray),
));
}
@@ -2136,9 +2158,25 @@ mod tests {
use super::*;
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::block::{ToolCallBlock, ToolCallState};
use protocol::WorkerStatus;
use protocol::Event;
use std::time::{Duration, Instant};
fn set_pending_submission(app: &mut App, id: &str) {
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 1,
notification_count: 0,
head_id: Some(id.into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: id.into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 1,
}],
},
});
}
#[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now();
@@ -2251,15 +2289,11 @@ mod tests {
#[test]
fn queue_status_text_includes_count_and_preview() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
for c in "queued preview".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
set_pending_submission(&mut app, "submission-1");
assert_eq!(
queue_status_text(&app),
Some("queued: 1 — queued preview".to_string())
Some("queued: 1 — submission-1".to_string())
);
}
@@ -2289,14 +2323,10 @@ mod tests {
Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into())
);
app.set_worker_status(WorkerStatus::Running);
for c in "queued turn".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
set_pending_submission(&mut app, "submission-1");
assert_eq!(
actionbar_left_item(&app, now).map(|(text, _)| text),
Some("Alt-q edit queued Alt-c clear queued".into())
Some("Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".into())
);
app.enter_command_mode();
+1
View File
@@ -14,6 +14,7 @@ fs-operation.workspace = true
manifest.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
File diff suppressed because it is too large Load Diff
+55 -43
View File
@@ -11,7 +11,8 @@ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
WorkdirSessionCapabilities, WriteRequest, WriteResult,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
WriteRequest, WriteResult,
};
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -55,6 +56,8 @@ pub struct OpenWorkdirSessionResponse {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
Stat(StatRequest),
Read(ReadRequest),
Write(WriteRequest),
@@ -68,12 +71,10 @@ pub enum WorkdirSessionOperation {
CommandCancel(CommandHandle),
}
/// Wire envelope for an operation and its optional provider-enforced child scope.
/// Wire envelope for one provider operation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<crate::WorkdirDelegationRequest>,
pub operation: WorkdirSessionOperation,
}
@@ -81,6 +82,8 @@ pub struct WorkdirSessionOperationRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
Stat(StatResult),
Read(ReadResult),
Write(WriteResult),
@@ -289,13 +292,18 @@ mod client {
use reqwest::{Client, StatusCode, Url};
use super::*;
use crate::{Workdir, WorkdirSession, WorkdirSessionHandle};
use crate::{Workdir, WorkdirSession};
/// Provides a fresh bearer token for each Runtime request. Backend
/// implementations can mint short-lived capability tokens without making a
/// Worker-bound session expire with the token used to open it.
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
fn bearer_token(&self) -> Result<String, WorkdirError>;
fn bearer_token(
&self,
method: &str,
path_and_query: &str,
body: &[u8],
) -> Result<String, WorkdirError>;
}
struct FixedBearerToken(Arc<str>);
@@ -307,7 +315,12 @@ mod client {
}
impl WorkdirHttpAuthorization for FixedBearerToken {
fn bearer_token(&self) -> Result<String, WorkdirError> {
fn bearer_token(
&self,
_method: &str,
_path_and_query: &str,
_body: &[u8],
) -> Result<String, WorkdirError> {
Ok(self.0.to_string())
}
}
@@ -324,7 +337,6 @@ mod client {
workdir: Workdir,
session_id: WorkdirSessionId,
capabilities: WorkdirSessionCapabilities,
delegations: Vec<crate::WorkdirDelegationRequest>,
closed: AtomicBool,
}
@@ -357,10 +369,14 @@ mod client {
&base_url,
&["v1", "working-directories", workdir_id.as_str(), "sessions"],
)?;
let body = serde_json::to_vec(&request)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = authorization.bearer_token("POST", url.path(), &body)?;
let response = client
.post(url)
.bearer_auth(authorization.bearer_token()?)
.json(&request)
.bearer_auth(token)
.header("content-type", "application/json")
.body(body)
.send()
.await
.map_err(http_unavailable)?;
@@ -377,7 +393,6 @@ mod client {
workdir: Workdir::new(opened.workdir_id.as_str()),
session_id: opened.session_id,
capabilities: opened.capabilities,
delegations: Vec::new(),
closed: AtomicBool::new(false),
})
}
@@ -404,15 +419,16 @@ mod client {
"operations",
],
)?;
let operation = WorkdirSessionOperationRequest {
delegations: self.delegations.clone(),
operation,
};
let operation = WorkdirSessionOperationRequest { operation };
let body = serde_json::to_vec(&operation)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = self.authorization.bearer_token("POST", url.path(), &body)?;
let response = self
.client
.post(url)
.bearer_auth(self.authorization.bearer_token()?)
.json(&operation)
.bearer_auth(token)
.header("content-type", "application/json")
.body(body)
.send()
.await
.map_err(http_unavailable)?;
@@ -436,35 +452,30 @@ mod client {
self.capabilities
}
fn transports_delegation_context(&self) -> bool {
true
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
match self
.operate(WorkdirSessionOperation::AuthorizeScope(request))
.await?
{
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
_ => Err(Self::mismatch("authorize_scope")),
}
}
async fn capture_delegation_source(
async fn scope_rules_overlap(
&self,
request: &crate::WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
if self.closed.load(Ordering::Acquire) {
return Err(WorkdirError::SessionClosed);
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
match self
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
.await?
{
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
_ => Err(Self::mismatch("scope_rules_overlap")),
}
let mut delegations = self.delegations.clone();
delegations.push(request.clone());
let candidate = Arc::new(Self {
client: self.client.clone(),
base_url: self.base_url.clone(),
authorization: self.authorization.clone(),
workdir: self.workdir.clone(),
session_id: self.session_id.clone(),
capabilities: self.capabilities,
delegations,
closed: AtomicBool::new(false),
});
candidate
.stat(StatRequest {
path: fs_operation::FsPath::new("").expect("empty Workdir path is valid"),
})
.await?;
Ok(candidate)
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
@@ -581,10 +592,11 @@ mod client {
&self.base_url,
&["v1", "workdir-sessions", self.session_id.as_str()],
)?;
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
let response = self
.client
.delete(url)
.bearer_auth(self.authorization.bearer_token()?)
.bearer_auth(token)
.send()
.await
.map_err(http_unavailable)?;
+26 -31
View File
@@ -5,10 +5,10 @@
//! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup.
mod delegation;
pub mod http;
mod local;
mod operation;
mod scope;
pub mod workspace;
use std::path::{Path, PathBuf};
@@ -18,11 +18,6 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
pub use delegation::{
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
apply_delegation_chain, delegation_capable_session,
};
pub use fs_operation::{
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
@@ -32,6 +27,11 @@ pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
};
pub use operation::*;
pub use scope::{
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
WorkdirToolScopeRule,
};
/// Persistent, opaque identity of one materialized Workdir.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -148,36 +148,31 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
fn workdir(&self) -> &Workdir;
fn capabilities(&self) -> WorkdirSessionCapabilities;
fn is_delegation_capable(&self) -> bool {
false
}
/// Whether this session transports the delegation chain to another
/// provider boundary that will apply logical cwd/path resolution there.
fn transports_delegation_context(&self) -> bool {
false
}
/// Capture a provider-specific source for a delegated child session.
/// Remote providers use this boundary to pin attachment identity without
/// exposing transport handles or host paths.
async fn capture_delegation_source(
/// Validate an attenuated filesystem rule at the provider boundary without
/// exposing the resolved host path. Providers that cannot resolve symbolic
/// links must reject resolved-policy checks rather than downgrade them.
async fn authorize_scope_path(
&self,
_request: &WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
Err(WorkdirError::Denied(
"workdir provider does not support delegated sessions".into(),
))
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
if request.rules.iter().any(|rule| {
rule.symlink_policy == manifest::SymlinkPolicy::Logical
&& scope::rule_allows_path(rule, &request.path, request.permission)
}) {
Ok(())
} else {
Err(WorkdirError::Denied(
"Workdir provider cannot establish resolved scope authority".to_string(),
))
}
}
/// Attenuate this session into a revocable child lease. Only sessions
/// created with [`delegation_capable_session`] implement this operation.
async fn delegate(
async fn scope_rules_overlap(
&self,
_request: WorkdirDelegationRequest,
) -> Result<WorkdirDelegation, WorkdirError> {
_request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied(
"workdir session is not delegation-capable".into(),
"Workdir provider cannot compare resolved scope authority".to_string(),
))
}
+358 -87
View File
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
use sha2::{Digest, Sha256};
use tokio::process::Command;
use tokio::sync::{Mutex, broadcast, watch};
@@ -28,9 +28,9 @@ use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
WriteResult,
};
#[cfg(test)]
@@ -213,6 +213,52 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
fn is_writable(&self, path: &Path) -> bool {
self.0.is_writable(path)
}
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
matches!(
self.0.permission_at_paths(logical, resolved),
Some(Permission::Read | Permission::Write)
)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
}
}
fn path_sets_overlap(
left: &Path,
left_recursive: bool,
right: &Path,
right_recursive: bool,
) -> bool {
match (left_recursive, right_recursive) {
(true, true) => left.starts_with(right) || right.starts_with(left),
(true, false) => {
right.starts_with(left)
|| left == right
|| left.parent().is_some_and(|parent| parent == right)
}
(false, true) => {
left.starts_with(right)
|| left == right
|| right.parent().is_some_and(|parent| parent == left)
}
(false, false) => {
left == right
|| left.parent().is_some_and(|parent| parent == right)
|| right.parent().is_some_and(|parent| parent == left)
}
}
}
fn rule_targets(
root: &Path,
rule: &crate::WorkdirToolScopeRule,
) -> std::io::Result<(PathBuf, PathBuf)> {
let logical = root.join(rule.target.as_str());
let resolved = fs_operation::resolve_access_path(&logical)?;
Ok((logical, resolved))
}
#[derive(Debug)]
@@ -399,6 +445,11 @@ impl LocalWorkdirSession {
return Err(WorkdirError::RelativePath(path.to_path_buf()));
}
let symlink = first_symlink(path);
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(broken_symlink_error(path, info));
}
let scope = self.inner.scope.load();
if !scope.is_readable(path) {
return Err(symlink_out_of_scope_or_plain(
@@ -408,11 +459,6 @@ impl LocalWorkdirSession {
&scope,
));
}
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(broken_symlink_error(path, info));
}
}
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
_ => WorkdirError::io(path, e),
@@ -558,67 +604,84 @@ impl WorkdirSession for LocalWorkdirSession {
self.inner.capabilities
}
async fn capture_delegation_source(
async fn authorize_scope_path(
&self,
request: &WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let host_rules = request
.rules
.iter()
.map(|rule| ScopeRule {
target: self.inner.root.join(rule.target.as_str()),
permission: match rule.permission {
WorkdirDelegationPermission::Read => Permission::Read,
WorkdirDelegationPermission::Write => Permission::Write,
},
recursive: rule.recursive,
})
.collect::<Vec<_>>();
for (logical, host) in request.rules.iter().zip(&host_rules) {
if logical.permission == WorkdirDelegationPermission::Write {
let resolved = Scope::resolved_target(host)
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
if resolved != host.target {
return Err(WorkdirError::Denied(format!(
"write delegation target `{}` traverses a symlink",
logical.target
)));
}
}
}
let parent_scope = self.inner.scope.snapshot();
for rule in &host_rules {
if !parent_scope
.allows_rule(rule)
.map_err(|error| WorkdirError::Denied(error.to_string()))?
{
return Err(WorkdirError::Denied(format!(
"delegated provider scope `{}` exceeds the parent session",
rule.target.display()
)));
}
}
let child_scope = Scope::from_config(&ScopeConfig {
allow: host_rules,
deny: Vec::new(),
})
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
let child_cwd = self.inner.root.join(request.cwd.as_str());
if !child_scope.is_readable(&child_cwd)
|| !std::fs::metadata(&child_cwd).is_ok_and(|metadata| metadata.is_dir())
{
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.ensure_open()?;
let logical = self.inner.root.join(request.path.as_str());
let resolved = fs_operation::resolve_access_path(&logical)
.map_err(|error| WorkdirError::io(&logical, error))?;
let parent_permission = self
.inner
.scope
.load()
.permission_at_paths(&logical, &resolved);
let parent_allows = match request.permission {
WorkdirToolScopePermission::Read => matches!(
parent_permission,
Some(Permission::Read | Permission::Write)
),
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
};
if !parent_allows {
return Err(WorkdirError::Denied(format!(
"delegated cwd `{}` is not a readable Workdir directory",
request.cwd
"Workdir path `{}` exceeds the provider attachment scope",
request.path
)));
}
Ok(Arc::new(LocalWorkdirSession::materialized_bound(
self.inner.workdir.clone(),
self.inner.root.clone(),
self.inner.root.clone(),
SharedScope::new(child_scope),
self.inner.capabilities,
)))
let allowed = request.rules.iter().any(|rule| {
if request.permission == WorkdirToolScopePermission::Write
&& rule.permission != WorkdirToolScopePermission::Write
{
return false;
}
let logical_target = self.inner.root.join(rule.target.as_str());
let (candidate, target) = match rule.symlink_policy {
SymlinkPolicy::Logical => (logical.as_path(), logical_target),
SymlinkPolicy::Resolved => {
let Ok(target) = fs_operation::resolve_access_path(&logical_target) else {
return false;
};
(resolved.as_path(), target)
}
};
if rule.recursive {
candidate.starts_with(target)
} else {
candidate == target || candidate.parent() == Some(target.as_path())
}
});
if allowed {
Ok(())
} else {
Err(WorkdirError::Denied(format!(
"Workdir path `{}` is outside the provider-resolved delegated scope",
request.path
)))
}
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.ensure_open()?;
let (left_logical, left_resolved) = rule_targets(&self.inner.root, &request.left)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
let (right_logical, right_resolved) = rule_targets(&self.inner.root, &request.right)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
Ok(path_sets_overlap(
&left_logical,
request.left.recursive,
&right_logical,
request.right.recursive,
) || path_sets_overlap(
&left_resolved,
request.left.recursive,
&right_resolved,
request.right.recursive,
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
@@ -694,9 +757,20 @@ impl WorkdirSession for LocalWorkdirSession {
{
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
}
let cwd = if let Some(logical_cwd) = request.cwd.as_ref() {
let cwd = self.resolve(logical_cwd);
let scope = self.inner.scope.snapshot();
if !scope.is_readable(&cwd)
|| !std::fs::metadata(&cwd).is_ok_and(|metadata| metadata.is_dir())
{
return Err(WorkdirError::OutOfScope(cwd));
}
cwd
} else {
self.inner.cwd.clone()
};
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
let handle = CommandHandle(format!("command-{id}"));
let cwd = self.inner.cwd.clone();
let (completion_tx, completion) = watch::channel(false);
let command_id = handle.0.clone();
let telemetry = self.inner.command_telemetry.clone();
@@ -1388,6 +1462,22 @@ mod tests {
)
}
fn make_logical_fs(dir: &TempDir) -> LocalWorkdirSession {
LocalWorkdirSession::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: Vec::new(),
})
.unwrap(),
dir.path().to_path_buf(),
)
}
#[tokio::test]
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
let dir = TempDir::new().unwrap();
@@ -1516,6 +1606,7 @@ mod tests {
command: "sleep 30".to_owned(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -1586,6 +1677,102 @@ mod tests {
assert_eq!(read.bytes, b"persisted");
}
#[cfg(unix)]
#[tokio::test]
async fn resolved_provider_scope_rejects_read_and_write_through_outside_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let target = outside.path().join("target.txt");
fs::write(&target, "secret").unwrap();
symlink(&target, root.path().join("alias.txt")).unwrap();
symlink(outside.path(), root.path().join("alias-dir")).unwrap();
let workdir = make_fs(&root);
assert!(matches!(
WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
content: b"changed".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert_eq!(fs::read_to_string(target).unwrap(), "secret");
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias-dir/new.txt").unwrap(),
content: b"new".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("new.txt").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn resolved_deny_blocks_missing_write_through_logical_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
symlink(outside.path(), root.path().join("alias")).unwrap();
let workdir = LocalWorkdirSession::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: vec![ScopeRule {
target: outside.path().join("blocked.txt"),
permission: Permission::Read,
recursive: false,
symlink_policy: SymlinkPolicy::Logical,
}],
})
.unwrap(),
root.path().to_path_buf(),
);
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias/blocked.txt").unwrap(),
content: b"blocked".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("blocked.txt").exists());
}
#[tokio::test]
async fn capability_boundary_rejects_direct_unsupported_operation() {
let dir = TempDir::new().unwrap();
@@ -1688,7 +1875,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn read_bytes_reports_symlink_target_outside_scope() {
fn read_bytes_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
@@ -1698,16 +1885,8 @@ mod tests {
let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap();
let fs = make_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err();
assert!(
matches!(
err,
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected symlink out-of-scope diagnostic, got {err:?}"
);
let fs = make_logical_fs(&dir);
assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
}
#[cfg(unix)]
@@ -1799,7 +1978,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn write_reports_symlink_target_outside_scope() {
fn write_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
@@ -1809,15 +1988,14 @@ mod tests {
let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap();
let fs = make_fs(&dir);
let err = fs.write(&link, b"new").unwrap_err();
let fs = make_logical_fs(&dir);
fs.write(&link, b"new").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!(
matches!(
err,
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected write symlink out-of-scope diagnostic, got {err:?}"
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
}
@@ -1840,11 +2018,13 @@ mod tests {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
deny: vec![ScopeRule {
target: sub.clone(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
};
let scope = Scope::from_config(&cfg).unwrap();
@@ -1908,6 +2088,7 @@ mod tests {
target: extra.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}])
})
.unwrap();
@@ -1944,6 +2125,7 @@ mod tests {
target: sub.clone(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}])
})
.unwrap();
@@ -1980,6 +2162,7 @@ mod tests {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}])
})
.unwrap();
@@ -1995,6 +2178,83 @@ mod tests {
));
}
#[cfg(unix)]
#[tokio::test]
async fn provider_uses_explicit_logical_policy_through_symlinked_directories() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
let workdir = make_logical_fs(&dir);
let read = WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("yoi.local/worker.json").unwrap(),
offset: 0,
limit: 100,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(read.bytes, b"scope-needle\n");
let list = WorkdirSession::list(
&workdir,
ListRequest {
path: WorkdirPath::new("yoi.local").unwrap(),
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
list.entries[0].path,
WorkdirPath::new("yoi.local/worker.json").unwrap()
);
let glob = WorkdirSession::glob(
&workdir,
GlobRequest {
pattern: "**/*.json".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
glob.paths,
[WorkdirPath::new("yoi.local/worker.json").unwrap()]
);
let grep = WorkdirSession::grep(
&workdir,
GrepRequest {
pattern: "scope-needle".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
glob: Some("*.json".into()),
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: crate::GrepOutputMode::Content,
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(grep.match_count, 1);
assert!(grep.output.contains("yoi.local/worker.json"));
assert!(
!workdir
.scope()
.is_readable(&outside.path().join("worker.json"))
);
}
#[tokio::test]
async fn provider_executes_glob_grep_and_command_at_the_materialization() {
let dir = TempDir::new().unwrap();
@@ -2043,6 +2303,7 @@ mod tests {
command: "pwd && printf provider-command".into(),
timeout_secs: 5,
output_limit: 4096,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -2081,11 +2342,13 @@ mod tests {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
},
ScopeRule {
target: spill.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
},
],
deny: Vec::new(),
@@ -2141,6 +2404,7 @@ mod tests {
command: "printf hidden".into(),
timeout_secs: 5,
output_limit: 1,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None,
},
@@ -2161,11 +2425,13 @@ mod tests {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
},
ScopeRule {
target: spill.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
},
],
deny: Vec::new(),
@@ -2178,6 +2444,7 @@ mod tests {
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
timeout_secs: 5,
output_limit: 64,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None,
},
@@ -2224,6 +2491,7 @@ mod tests {
command: "printf 'aéz'".into(),
timeout_secs: 5,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -2449,6 +2717,7 @@ mod tests {
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
timeout_secs: 5,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: Some("tool-7".into()),
},
@@ -2553,6 +2822,7 @@ mod tests {
command: "sleep 30".into(),
timeout_secs: 1,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -2623,6 +2893,7 @@ mod tests {
command: "sleep 30".into(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
+4
View File
@@ -11,6 +11,10 @@ pub struct CommandRequest {
pub command: String,
pub timeout_secs: u64,
pub output_limit: usize,
/// Workdir-relative command directory. Providers validate it against the
/// active session before process start.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<fs_operation::FsPath>,
/// Provider-local directory where complete output is retained when the
/// inline result exceeds `output_limit`.
pub spill_dir: Option<PathBuf>,
File diff suppressed because it is too large Load Diff
-10
View File
@@ -104,15 +104,5 @@ mod tests {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_session_fence: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<crate::WorkdirDelegationRequest>,
pub operation: crate::http::WorkdirSessionOperation,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionFence {
pub value: String,
}
+3 -1
View File
@@ -39,7 +39,9 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
ring.workspace = true
tar.workspace = true
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] }
tracing.workspace = true
tracing-subscriber.workspace = true
toml.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
+87 -220
View File
@@ -2,6 +2,7 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
const TOKEN_PREFIX: &str = "yoi-cap-v1";
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
WrongMutationTarget,
}
pub(crate) struct SignedJsonToken<T> {
pub payload: String,
pub signature: Vec<u8>,
pub claims: T,
}
pub(crate) fn sign_json_token<T: Serialize>(
token_prefix: &str,
signing_input_prefix: &str,
signing_key: &Ed25519KeyPair,
claims: &T,
) -> Result<String, RuntimeAuthError> {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
let signing_input = format!("{signing_input_prefix}{payload}");
let signature = signing_key.sign(signing_input.as_bytes());
Ok(format!(
"{token_prefix}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
pub(crate) fn decode_signed_json_token<T: DeserializeOwned>(
token: &str,
expected_prefix: &str,
) -> Result<SignedJsonToken<T>, RuntimeAuthError> {
let (prefix, payload, signature) = split_three_part_token(token)?;
if prefix != expected_prefix {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
Ok(SignedJsonToken {
payload: payload.to_string(),
signature,
claims,
})
}
pub(crate) fn verify_signed_json_token(
signing_input_prefix: &str,
payload: &str,
signature: &[u8],
public_key: &str,
) -> Result<(), RuntimeAuthError> {
let public_key = decode_public_key(public_key)?;
let signing_input = format!("{signing_input_prefix}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)
}
fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = token.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
pub(crate) fn is_request_body_digest(value: &str) -> bool {
URL_SAFE_NO_PAD
.decode(value)
.is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeIdentityMaterial {
pub identity_id: String,
@@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial {
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedServerKey {
pub server_id: String,
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpAuthConfig {
pub runtime_id: String,
#[serde(default)]
pub trusted_servers: Vec<TrustedServerKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthContext {
pub server_id: String,
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
pub expires_at: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
pub permissions: Vec<String>,
pub exp: u64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapabilityTokenSigner {
server_id: String,
private_key: String,
}
impl CapabilityTokenSigner {
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
Self {
server_id: server_id.into(),
private_key: private_key.into(),
}
}
pub fn server_id(&self) -> &str {
&self.server_id
}
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
if claims.iss != self.server_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
}
let private = decode_private_key(&self.private_key)?;
let pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let payload = serde_json::to_vec(claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
let signature = pair.sign(signing_input.as_bytes());
Ok(format!(
"{TOKEN_PREFIX}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
}
pub fn capability_claims(
server_id: impl Into<String>,
runtime_id: impl Into<String>,
workspace_id: impl Into<String>,
permissions: Vec<String>,
ttl_seconds: u64,
) -> Result<CapabilityClaims, RuntimeAuthError> {
let exp = unix_now_seconds().saturating_add(ttl_seconds);
Ok(CapabilityClaims {
iss: server_id.into(),
aud: runtime_id.into(),
workspace_id: workspace_id.into(),
permissions,
exp,
jti: new_token_id()?,
})
}
pub fn verify_capability_token(
config: &RuntimeHttpAuthConfig,
token: &str,
required_permission: Option<&str>,
now_seconds: u64,
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
let (payload, signature) = split_token(token)?;
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
let Some(server) = config
.trusted_servers
.iter()
.find(|server| server.server_id == claims.iss)
else {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
};
let public_key = decode_public_key(&server.public_key)?;
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
if claims.aud != config.runtime_id {
return Err(RuntimeAuthError::WrongAudience {
expected: config.runtime_id.clone(),
actual: claims.aud,
});
}
if claims.exp < now_seconds {
return Err(RuntimeAuthError::Expired);
}
if claims.workspace_id.trim().is_empty() {
return Err(RuntimeAuthError::MissingWorkspaceScope);
}
if let Some(required) = required_permission {
if !claims
.permissions
.iter()
.any(|permission| permission == required)
{
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
}
}
Ok(RuntimeAuthContext {
server_id: claims.iss,
workspace_id: claims.workspace_id,
permissions: claims.permissions,
token_id: claims.jti,
expires_at: claims.exp,
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims {
pub iss: String,
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?,
};
let payload = serde_json::to_vec(&claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref());
Ok(format!(
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}"
))
sign_json_token(
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
&key_pair,
&claims,
)
}
}
pub fn decode_runtime_request_source_claims(
proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
}
pub fn verify_runtime_request_source(
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let public_key = decode_public_key(public_key)?;
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
let claims = decode_runtime_request_source_claims(proof)?;
let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
proof,
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
)?;
verify_signed_json_token(
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
&signed.payload,
&signed.signature,
public_key,
)?;
let claims = signed.claims;
if claims.iss != expected.identity_id
|| claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
Ok(claims)
}
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims {
pub iss: String,
@@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
}
}
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
}
_ => Err(RuntimeAuthError::InvalidTokenFormat),
}
}
pub fn encode_public_key(bytes: &[u8]) -> String {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
}
@@ -851,46 +760,4 @@ mod tests {
Err(RuntimeAuthError::Expired)
));
}
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
let claims = CapabilityClaims {
iss: "server-main".to_string(),
aud: "runtime-main".to_string(),
workspace_id: "workspace-a".to_string(),
permissions: vec!["workers:list".to_string()],
exp: 100,
jti: "token-1".to_string(),
};
let token = signer.sign(&claims).unwrap();
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-main".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: "server-main".to_string(),
public_key: server.public_key.clone(),
display_name: None,
}],
};
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
assert_eq!(context.workspace_id, "workspace-a");
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:create"), 99),
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
));
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:list"), 101),
Err(RuntimeAuthError::Expired)
));
let wrong_audience = RuntimeHttpAuthConfig {
runtime_id: "other-runtime".to_string(),
trusted_servers: auth.trusted_servers.clone(),
};
assert!(matches!(
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
Err(RuntimeAuthError::WrongAudience { .. })
));
}
}
+18 -19
View File
@@ -15,32 +15,22 @@ pub enum ProfileSelector {
Named(String),
}
/// Runtime fetch/caching metadata for a Backend-authored Decodal profile source archive.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileSourceArchiveHttpRef {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
pub archive: ProfileSourceArchiveRef,
}
/// Profile source material available to a Runtime during Worker creation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSourceArchiveSource {
/// Backend-internal embedded runtimes may receive already-built archive bytes.
Embedded { archive: ProfileSourceArchive },
/// Standalone runtimes fetch/cache the tar archive over HTTP.
Http {
location: ProfileSourceArchiveHttpRef,
},
/// Standalone runtimes resolve this immutable archive from the latest
/// Workspace Config bundle before creating the Worker.
WorkspaceConfig { archive: ProfileSourceArchiveRef },
}
impl ProfileSourceArchiveSource {
pub fn reference(&self) -> ProfileSourceArchiveRef {
match self {
Self::Embedded { archive } => archive.reference.clone(),
Self::Http { location } => location.archive.clone(),
Self::WorkspaceConfig { archive } => archive.clone(),
}
}
}
@@ -129,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub struct RepositorySshCredentialCandidate {
pub credential_id: String,
pub credential_revision: u64,
#[serde(skip, default)]
pub private_key: SensitiveString,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode,
@@ -141,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString,
}
@@ -153,8 +148,6 @@ pub struct RepositoryMaterializationContext {
pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>,
}
@@ -307,6 +300,8 @@ pub struct WorkerSummary {
pub worker_id: WorkerId,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>,
@@ -325,6 +320,8 @@ pub struct WorkerDetail {
pub worker_id: WorkerId,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>,
@@ -341,6 +338,8 @@ pub struct WorkerDetail {
pub struct WorkerLifecycleAck {
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
}
#[cfg(test)]
@@ -9,6 +9,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256";
pub const WORKSPACE_CONFIG_ETAG_PREFIX: &str = "workspace-config:";
pub fn workspace_config_etag(digest: &str) -> String {
format!("\"{WORKSPACE_CONFIG_ETAG_PREFIX}{digest}\"")
}
/// Backend-synced Profile/config bundle stored by a Runtime.
///
+70 -45
View File
@@ -1,6 +1,7 @@
use crate::catalog::{
RepositoryRefObservation, RepositoryRefObservationRequest,
ConfigBundleRef, ProfileSelector, RepositoryRefObservation, RepositoryRefObservationRequest,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
};
use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError;
@@ -15,18 +16,6 @@ use std::fmt;
use std::sync::Arc;
use workdir::WorkdirSessionHandle;
/// Current execution-side run state for a Worker.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionRunState {
#[default]
Stopped,
Idle,
Busy,
Rejected,
Errored,
}
/// Execution operation that produced a result.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -41,14 +30,12 @@ pub enum WorkerExecutionOperation {
Cancel,
}
/// Evidence that a user input reached the durable Worker session boundary.
///
/// This is intentionally distinct from accepting a method on the Worker's
/// in-memory channel. For Flow submissions, the committed UserInput entry also
/// carries the initial Flow runtime-state extension.
/// Evidence that a Submit request reached the durable Worker session boundary.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerInputCommitAck {
pub struct WorkerSubmissionAck {
pub submission_request_id: String,
pub submission_id: String,
pub disposition: protocol::SubmissionDisposition,
}
/// Typed execution result class. Results are transient operation outcomes and
@@ -57,11 +44,12 @@ pub struct WorkerInputCommitAck {
pub struct WorkerExecutionResult {
pub operation: WorkerExecutionOperation,
pub outcome: WorkerExecutionOutcome,
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_commit: Option<WorkerInputCommitAck>,
pub submission: Option<WorkerSubmissionAck>,
}
/// Backend result class for a Worker execution operation.
@@ -76,31 +64,36 @@ pub enum WorkerExecutionOutcome {
}
impl WorkerExecutionResult {
pub fn accepted(
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
) -> Self {
pub fn accepted(operation: WorkerExecutionOperation) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Accepted,
run_state,
worker_state: None,
message: None,
input_commit: None,
submission: None,
}
}
pub fn accepted_input_committed(
pub fn with_worker_state(mut self, worker_state: protocol::WorkerStateSnapshot) -> Self {
self.worker_state = Some(worker_state);
self
}
pub fn accepted_submission(
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
submission_request_id: impl Into<String>,
submission_id: impl Into<String>,
disposition: protocol::SubmissionDisposition,
) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Accepted,
run_state,
worker_state: None,
message: None,
input_commit: Some(WorkerInputCommitAck {
submission: Some(WorkerSubmissionAck {
submission_request_id: submission_request_id.into(),
submission_id: submission_id.into(),
disposition,
}),
}
}
@@ -109,9 +102,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Busy,
run_state: WorkerExecutionRunState::Busy,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -119,9 +112,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Rejected,
run_state: WorkerExecutionRunState::Stopped,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -129,9 +122,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Errored,
run_state: WorkerExecutionRunState::Errored,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -139,9 +132,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Unsupported,
run_state: WorkerExecutionRunState::Stopped,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -273,12 +266,28 @@ pub struct WorkerExecutionRestoreRequest {
pub config_bundle: Option<ConfigBundle>,
}
/// Runtime-side request to refresh the latest Workspace Config before Worker creation.
#[derive(Clone, Debug)]
pub struct WorkspaceConfigFetchRequest {
pub workspace_api: WorkspaceApiRef,
pub profile: ProfileSelector,
pub expected: ConfigBundleRef,
pub cached: Option<ConfigBundleRef>,
}
/// Result of a conditional Workspace Config fetch.
#[derive(Clone, Debug)]
pub enum WorkspaceConfigFetchResult {
NotModified,
Modified(ConfigBundle),
}
/// Backend outcome for Worker spawn/restore operations.
#[derive(Clone, Debug)]
pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
},
Rejected(WorkerExecutionResult),
@@ -288,12 +297,12 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult {
pub fn connected(
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
) -> Self {
Self::Connected {
handle,
run_state,
worker_state,
working_directory,
}
}
@@ -302,6 +311,13 @@ impl WorkerExecutionSpawnResult {
pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str;
fn fetch_workspace_config(
&self,
_request: WorkspaceConfigFetchRequest,
) -> Result<WorkspaceConfigFetchResult, String> {
Err("execution backend does not support Workspace Config fetching".to_string())
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
fn restore_worker(
@@ -483,6 +499,13 @@ impl WorkerExecutionBackendRef {
})
}
pub(crate) fn fetch_workspace_config(
&self,
request: WorkspaceConfigFetchRequest,
) -> Result<WorkspaceConfigFetchResult, String> {
self.backend.fetch_workspace_config(request)
}
pub(crate) fn spawn_worker(
&self,
request: WorkerExecutionSpawnRequest,
@@ -618,14 +641,16 @@ mod tests {
use super::*;
#[test]
fn input_commit_ack_survives_json_round_trip() {
let result = WorkerExecutionResult::accepted_input_committed(
fn submission_ack_survives_json_round_trip() {
let result = WorkerExecutionResult::accepted_submission(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
"request-1",
"submission-1",
protocol::SubmissionDisposition::Started,
);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"submission_request_id\":\"request-1\""));
assert!(json.contains("\"submission_id\":\"submission-1\""));
assert_eq!(
serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(),
+289 -28
View File
@@ -15,7 +15,10 @@ use std::io::{BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 4;
const SCHEMA_VERSION: u32 = 6;
const PREVIOUS_SCHEMA_VERSION: u32 = 5;
const EXECUTION_SCHEMA_VERSION: u32 = 4;
const PRE_EXECUTION_SCHEMA_VERSION: u32 = 3;
const RUNTIME_FILE: &str = "runtime.json";
const WORKERS_DIR: &str = "workers";
const WORKER_FILE: &str = "worker.json";
@@ -283,6 +286,7 @@ pub(crate) struct PersistedWorkerExecutionBinding {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistedWorkerExecution {
pub(crate) last_run_generation: u64,
pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
pub(crate) restore_intent: WorkerRestoreIntent,
}
@@ -370,8 +374,8 @@ fn plan_runtime_store_migration(
format!("Runtime store schema version {schema_version} is out of range"),
)
})?;
let staging = migration_sibling(root, "schema-v4-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
let staging = migration_sibling(root, "schema-v6-staging")?;
let backup = migration_sibling(root, "pre-schema-v6-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
@@ -397,11 +401,14 @@ fn plan_runtime_store_migration(
};
return Ok((plan, Vec::new()));
}
if current_schema_version != 3 {
if !matches!(
current_schema_version,
PRE_EXECUTION_SCHEMA_VERSION | EXECUTION_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION
) {
return Err(runtime_store_corrupt(
&runtime_path,
format!(
"unsupported Runtime store schema version {schema_version}; expected 3 or {SCHEMA_VERSION}"
"unsupported Runtime store schema version {schema_version}; expected {PRE_EXECUTION_SCHEMA_VERSION}, {EXECUTION_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
),
));
}
@@ -428,6 +435,16 @@ fn plan_runtime_store_migration(
runtime_store_corrupt(&source_dir, "Worker directory is not UTF-8".to_string())
})?;
let snapshot_path = source_dir.join(WORKER_FILE);
if !snapshot_path
.try_exists()
.map_err(|source| RuntimeError::StoreIo {
operation: "inspect Worker snapshot",
path: snapshot_path.clone(),
source,
})?
{
continue;
}
let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 {
let legacy_worker_id = name.parse::<u64>().map_err(|_| {
@@ -616,6 +633,38 @@ fn migrate_v1_worker_document(
Ok(snapshot)
}
fn max_persisted_run_generation(snapshot_path: &Path) -> Result<u64, RuntimeError> {
let worker_dir = snapshot_path.parent().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker snapshot path is missing its aggregate directory".to_string(),
)
})?;
let runs_dir = worker_dir.join("runs");
if !runs_dir
.try_exists()
.map_err(|source| runtime_io_error("inspect Worker runs", &runs_dir, source))?
{
return Ok(0);
}
let entries = fs::read_dir(&runs_dir)
.map_err(|source| runtime_io_error("read Worker runs", &runs_dir, source))?;
let mut max_generation = 0;
for entry in entries {
let entry =
entry.map_err(|source| runtime_io_error("read Worker runs", &runs_dir, source))?;
let Some(generation) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u64>().ok())
else {
continue;
};
max_generation = max_generation.max(generation);
}
Ok(max_generation)
}
fn migrate_worker_document(
mut document: serde_json::Value,
source_schema_version: u32,
@@ -640,7 +689,7 @@ fn migrate_worker_document(
"Worker snapshot must be an object".to_string(),
)
})?;
let run_generation = object
let declared_run_generation = object
.remove("run_generation")
.map(|value| {
value.as_u64().ok_or_else(|| {
@@ -650,9 +699,45 @@ fn migrate_worker_document(
)
})
})
.transpose()?
.filter(|generation| *generation > 0);
.transpose()?;
let legacy_execution = object.remove("execution");
let execution = legacy_execution
.as_ref()
.and_then(serde_json::Value::as_object);
let persisted_last_run_generation = execution
.and_then(|execution| execution.get("last_run_generation"))
.map(|value| {
value.as_u64().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker execution last_run_generation must be an unsigned integer".to_string(),
)
})
})
.transpose()?;
let binding_run_generation = execution
.and_then(|execution| execution.get("binding"))
.and_then(serde_json::Value::as_object)
.and_then(|binding| binding.get("run_generation"))
.map(|value| {
value.as_u64().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker execution binding run_generation must be an unsigned integer"
.to_string(),
)
})
})
.transpose()?;
let run_generation = declared_run_generation
.into_iter()
.chain(persisted_last_run_generation)
.chain(binding_run_generation)
.chain(std::iter::once(max_persisted_run_generation(
snapshot_path,
)?))
.max()
.unwrap_or(0);
if !object.contains_key("working_directory") {
if let Some(working_directory) = legacy_execution
.as_ref()
@@ -663,6 +748,42 @@ fn migrate_worker_document(
object.insert("working_directory".to_string(), working_directory);
}
}
let legacy_materialization = object
.get("working_directory")
.and_then(|working_directory| working_directory.get("summary"))
.and_then(|summary| summary.get("materializer_kind"))
.and_then(serde_json::Value::as_str)
.is_some_and(|kind| matches!(kind, "runtime_git_cache" | "local_git_worktree"));
if legacy_materialization {
object.insert("working_directory".to_string(), serde_json::Value::Null);
}
if let Some(profile_source) = object
.get_mut("request")
.and_then(serde_json::Value::as_object_mut)
.and_then(|request| request.get_mut("profile_source"))
.and_then(serde_json::Value::as_object_mut)
&& profile_source
.get("kind")
.and_then(serde_json::Value::as_str)
== Some("http")
{
let archive = profile_source
.get_mut("location")
.and_then(serde_json::Value::as_object_mut)
.and_then(|location| location.remove("archive"))
.ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"legacy HTTP profile source is missing its archive".to_string(),
)
})?;
profile_source.clear();
profile_source.insert(
"kind".to_string(),
serde_json::Value::String("workspace_config".to_string()),
);
profile_source.insert("archive".to_string(), archive);
}
object.insert(
"schema_version".to_string(),
serde_json::Value::from(SCHEMA_VERSION),
@@ -674,9 +795,8 @@ fn migrate_worker_document(
object.insert(
"execution".to_string(),
serde_json::json!({
"binding": run_generation.map(|run_generation| {
serde_json::json!({ "run_generation": run_generation })
}),
"last_run_generation": run_generation,
"binding": null,
"restore_intent": "explicit",
}),
);
@@ -759,8 +879,8 @@ fn migrate_worker_aggregate_document(
.get_mut("resolved_manifest_snapshot")
.filter(|snapshot| !snapshot.is_null())
{
let manifest: manifest::WorkerManifest =
serde_json::from_value(snapshot.clone()).map_err(|error| {
let mut manifest = manifest::read_persisted_worker_manifest_snapshot(snapshot.clone())
.map_err(|error| {
runtime_store_corrupt(
metadata_path,
format!("decode Worker aggregate resolved manifest snapshot: {error}"),
@@ -775,20 +895,14 @@ fn migrate_worker_aggregate_document(
),
));
}
snapshot
.as_object_mut()
.and_then(|manifest| manifest.get_mut("worker"))
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
manifest.worker.name = expected_name.clone();
*snapshot =
manifest::write_persisted_worker_manifest_snapshot(&manifest).map_err(|error| {
runtime_store_corrupt(
metadata_path,
"Worker aggregate resolved manifest is missing worker metadata".to_string(),
format!("encode migrated Worker aggregate resolved manifest: {error}"),
)
})?
.insert(
"name".to_string(),
serde_json::Value::String(expected_name.clone()),
);
})?;
}
metadata.insert(
"worker_name".to_string(),
@@ -809,8 +923,8 @@ fn migrate_worker_aggregate_document(
));
}
if let Some(snapshot) = metadata.resolved_manifest_snapshot {
let manifest: manifest::WorkerManifest =
serde_json::from_value(snapshot).map_err(|error| {
let manifest =
manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|error| {
runtime_store_corrupt(
metadata_path,
format!("decode migrated Worker aggregate resolved manifest: {error}"),
@@ -1054,8 +1168,8 @@ fn migrate_runtime_store(
if !plan.migration_required {
return Ok(plan);
}
let staging = migration_sibling(root, "schema-v4-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
let staging = migration_sibling(root, "schema-v6-staging")?;
let backup = migration_sibling(root, "pre-schema-v6-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
@@ -1328,6 +1442,18 @@ impl WorkerSnapshot {
),
});
}
if let Some(binding) = self.execution.binding.as_ref()
&& binding.run_generation != self.execution.last_run_generation
{
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
path: path.to_path_buf(),
message: format!(
"execution binding run_generation {} does not match last_run_generation {}",
binding.run_generation, self.execution.last_run_generation
),
});
}
match (self.status, self.execution.restore_intent) {
(status, WorkerRestoreIntent::Automatic) if status.is_active() => {
let Some(binding) = self.execution.binding.as_ref() else {
@@ -1497,3 +1623,138 @@ fn sync_directory(path: &Path, operation: &'static str) -> Result<(), RuntimeErr
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_v4_migration_plan_ignores_orphan_worker_directories() {
let root = tempfile::tempdir().unwrap();
fs::write(
root.path().join(RUNTIME_FILE),
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": PREVIOUS_SCHEMA_VERSION,
"display_name": null,
"backend": "fs_store",
"status": "running",
"next_diagnostic_id": 1,
"config_bundles": {},
"workspace_owners": {},
"diagnostics": []
}))
.unwrap(),
)
.unwrap();
fs::create_dir_all(root.path().join(WORKERS_DIR).join("orphan").join("session")).unwrap();
fs::write(
root.path()
.join(WORKERS_DIR)
.join("orphan")
.join("session")
.join("history.json"),
b"[]",
)
.unwrap();
let (plan, _) = plan_runtime_store_migration(root.path(), "runtime-test").unwrap();
assert!(plan.migration_required);
assert_eq!(plan.current_schema_version, PREVIOUS_SCHEMA_VERSION);
assert_eq!(plan.target_schema_version, SCHEMA_VERSION);
assert_eq!(plan.worker_count, 0);
}
#[test]
fn schema_v5_worker_migration_recovers_last_generation_from_run_aggregates() {
let root = tempfile::tempdir().unwrap();
let worker_dir = root.path().join("worker-a");
fs::create_dir_all(worker_dir.join("runs/1")).unwrap();
fs::create_dir_all(worker_dir.join("runs/7")).unwrap();
fs::create_dir_all(worker_dir.join("runs/incomplete")).unwrap();
let path = worker_dir.join(WORKER_FILE);
let source = serde_json::json!({
"schema_version": 5,
"execution": {
"binding": null,
"restore_intent": "explicit"
}
});
let migrated =
migrate_worker_document(source, PREVIOUS_SCHEMA_VERSION, None, &path).unwrap();
assert_eq!(
migrated["execution"]["last_run_generation"],
serde_json::json!(7)
);
assert_eq!(migrated["execution"]["binding"], serde_json::Value::Null);
}
#[test]
fn schema_v4_worker_migration_discards_unsupported_linked_worktree_binding() {
let source = serde_json::json!({
"schema_version": 4,
"request": {
"profile_source": {
"kind": "http",
"location": {
"url": "https://workspace.example.test/archive",
"etag": "profile-source:test",
"archive": {
"id": "profiles-v1",
"digest": "sha256:test",
"size_bytes": 1,
"source_graph": {
"source_count": 1,
"total_source_bytes": 1,
"entrypoints": {},
"import_count": 0
}
}
}
}
},
"working_directory": {
"summary": {
"materializer_kind": "runtime_git_cache"
}
}
});
let path = Path::new("worker.json");
let migrated =
migrate_worker_document(source, EXECUTION_SCHEMA_VERSION, None, path).unwrap();
assert_eq!(migrated["schema_version"], SCHEMA_VERSION);
assert_eq!(migrated["status"], "stopped");
assert_eq!(migrated["working_directory"], serde_json::Value::Null);
assert_eq!(
migrated["request"]["profile_source"]["kind"],
"workspace_config"
);
assert_eq!(
migrated["request"]["profile_source"]["archive"]["id"],
"profiles-v1"
);
assert_eq!(migrated["execution"]["restore_intent"], "explicit");
}
#[test]
fn schema_v4_worker_migration_preserves_runtime_clone_observation() {
let source = serde_json::json!({
"schema_version": 4,
"working_directory": {
"summary": {
"materializer_kind": "runtime_git_clone"
}
}
});
let expected = source["working_directory"].clone();
let path = Path::new("worker.json");
let migrated =
migrate_worker_document(source, EXECUTION_SCHEMA_VERSION, None, path).unwrap();
assert_eq!(migrated["working_directory"], expected);
}
}
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -25,10 +25,10 @@ impl WorkerInputKind {
pub struct WorkerInput {
pub kind: WorkerInputKind,
pub content: String,
/// Runtime-generated correlation id. This is never accepted from public
/// JSON input and is consumed only by the execution backend.
#[serde(skip)]
pub submission_id: Option<String>,
/// Authenticated client-generated idempotency key. Runtime generates one
/// only for trusted internal callers that omit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<Segment>>,
}
@@ -38,7 +38,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::User,
content: content.into(),
submission_id: None,
submission_request_id: None,
segments: None,
}
}
@@ -47,7 +47,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::Notify,
content: content.into(),
submission_id: None,
submission_request_id: None,
segments: None,
}
}
@@ -57,6 +57,21 @@ impl WorkerInput {
mod tests {
use super::WorkerInput;
#[test]
fn submission_request_id_round_trips_for_authenticated_client_retry() {
let input: WorkerInput = serde_json::from_value(serde_json::json!({
"kind": "user",
"content": "message",
"submission_request_id": "request-1"
}))
.unwrap();
assert_eq!(input.submission_request_id.as_deref(), Some("request-1"));
assert_eq!(
serde_json::to_value(input).unwrap()["submission_request_id"],
"request-1"
);
}
#[test]
fn notify_is_an_operation_and_legacy_system_kind_is_rejected() {
assert_eq!(
@@ -78,4 +93,7 @@ mod tests {
pub struct WorkerInteractionAck {
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
/// Present for User Submit and absent for non-Submit interactions.
#[serde(skip_serializing_if = "Option::is_none")]
pub submission: Option<crate::execution::WorkerSubmissionAck>,
}
+4
View File
@@ -25,9 +25,13 @@ pub mod resource;
#[cfg(feature = "fs-store")]
pub mod retention;
mod runtime;
pub mod ssh_host_key_probe;
pub mod worker_backend;
pub mod worker_source;
pub mod working_directory;
pub mod workspace_issuer;
#[cfg(feature = "http-server")]
pub mod workspace_request;
#[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
File diff suppressed because it is too large Load Diff
+159 -81
View File
@@ -1,13 +1,9 @@
use crate::auth::{
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION;
use crate::identity::WorkerId;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar";
@@ -15,16 +11,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15);
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecretCandidate {
pub credential_id: String,
pub credential_revision: u64,
pub private_key: String,
}
impl Drop for RepositorySshAccessSecretCandidate {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
}
}
impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecretCandidate")
.field("credential_id", &self.credential_id)
.field("credential_revision", &self.credential_revision)
.field("private_key", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret {
pub private_key: String,
pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
pub known_hosts_entry: String,
}
impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
}
}
@@ -33,7 +54,7 @@ impl std::fmt::Debug for RepositorySshAccessSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]")
.field("credential_candidates", &self.credential_candidates)
.field("known_hosts_entry", &"[REDACTED]")
.finish()
}
@@ -144,6 +165,8 @@ pub enum BackendResourceError {
Oversized { max_bytes: u64, actual_bytes: u64 },
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
ContentTypeMismatch { expected: String, actual: String },
#[error("backend resource fetch timed out")]
Timeout,
#[error("backend resource transport failed: {message}")]
Transport { message: String },
#[error("backend resource response is invalid: {message}")]
@@ -163,9 +186,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
pub struct HttpBackendResourceClient {
endpoint: String,
bearer_token: Option<String>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
client: reqwest::Client,
workspace_request_client: Option<RuntimeWorkspaceRequestClient>,
request_timeout: std::time::Duration,
}
#[cfg(feature = "http-server")]
@@ -174,19 +196,18 @@ impl HttpBackendResourceClient {
Self {
endpoint: endpoint.into(),
bearer_token,
request_source_signer: None,
request_source_audience: None,
client: reqwest::Client::new(),
workspace_request_client: None,
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
}
}
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
self.request_source_audience = Some(audience.into());
pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn with_workspace_request_client(mut self, client: RuntimeWorkspaceRequestClient) -> Self {
self.workspace_request_client = Some(client);
self
}
}
@@ -208,55 +229,73 @@ impl BackendResourceClient for HttpBackendResourceClient {
message: error.to_string(),
}
})?;
let mut builder = self
.client
.post(endpoint.clone())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some(signer) = self.request_source_signer.as_ref() {
let audience = self.request_source_audience.as_deref().ok_or_else(|| {
BackendResourceError::Unauthorized {
message: "Runtime request proof audience is unavailable".to_owned(),
}
})?;
let proof = signer
.issue(
audience,
&request.handle.workspace_id,
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"POST",
endpoint.path(),
&body,
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| BackendResourceError::Unauthorized {
let client = self.workspace_request_client.as_ref().ok_or_else(|| {
BackendResourceError::Unauthorized {
message: "Workspace request client is unavailable".to_string(),
}
})?;
if client.workspace_id() != request.handle.workspace_id {
return Err(BackendResourceError::Unauthorized {
message: "Workspace request client does not match the resource workspace"
.to_string(),
});
}
let base_url = client.base_url().trim_end_matches('/');
let endpoint_text = endpoint.as_str();
let endpoint_suffix = endpoint_text.strip_prefix(base_url).ok_or_else(|| {
BackendResourceError::Unauthorized {
message: "Workspace resource endpoint does not match its request client"
.to_string(),
}
})?;
if !endpoint_suffix.starts_with('/') {
return Err(BackendResourceError::Unauthorized {
message: "Workspace resource endpoint does not match its request client"
.to_string(),
});
}
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
if let Some(token) = self.bearer_token.as_deref() {
let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|error| BackendResourceError::Transport {
message: error.to_string(),
})?;
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
headers.insert(reqwest::header::AUTHORIZATION, value);
}
let builder = if let Some(token) = self.bearer_token.as_deref() {
builder.bearer_auth(token)
} else {
builder
};
let response = builder
.send()
let response = client
.execute(RuntimeWorkspaceRequest {
method: reqwest::Method::POST,
path_and_query: endpoint_suffix.to_string(),
body,
headers,
permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(),
worker_id: None,
timeout: Some(self.request_timeout),
max_response_bytes: 8 * 1024 * 1024,
})
.await
.map_err(|err| BackendResourceError::Transport {
message: err.to_string(),
.map_err(|error| {
if error.is_timeout() {
BackendResourceError::Timeout
} else {
BackendResourceError::Transport {
message: error.to_string(),
}
}
})?;
if response.status().is_success() {
response
.json::<BackendResourceFetchResponse>()
.await
.map_err(|err| BackendResourceError::InvalidResponse {
if response.status.is_success() {
serde_json::from_slice::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
BackendResourceError::InvalidResponse {
message: err.to_string(),
})
}
})
} else {
let status = response.status();
match response.json::<BackendResourceError>().await {
let status = response.status;
match serde_json::from_slice::<BackendResourceError>(&response.body) {
Ok(error) => Err(error),
Err(err) => Err(BackendResourceError::Transport {
message: format!("backend resource fetch failed with HTTP {status}: {err}"),
@@ -266,23 +305,6 @@ impl BackendResourceClient for HttpBackendResourceClient {
}
}
#[derive(Default, Debug)]
pub struct ProfileSourceArchiveCache {
archives: Mutex<HashMap<String, ProfileSourceArchive>>,
}
impl ProfileSourceArchiveCache {
pub fn get(&self, digest: &str) -> Option<ProfileSourceArchive> {
self.archives.lock().ok()?.get(digest).cloned()
}
pub fn insert(&self, archive: ProfileSourceArchive) {
if let Ok(mut archives) = self.archives.lock() {
archives.insert(archive.reference.digest.clone(), archive);
}
}
}
pub fn build_profile_source_archive_fetch_request(
handle: BackendResourceHandle,
runtime_id: &str,
@@ -364,6 +386,7 @@ pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), Str
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::RuntimeIdentityMaterial;
use crate::profile_archive::ProfileSourceGraphSummary;
use std::collections::BTreeMap;
@@ -401,6 +424,61 @@ mod tests {
}
}
#[cfg(feature = "http-server")]
#[tokio::test]
async fn http_backend_resource_fetch_has_a_bounded_timeout() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
futures::future::pending::<()>().await;
drop(stream);
});
let base_url = format!("http://{address}");
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let handle = handle_for(b"archive-bytes");
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
.with_request_timeout(std::time::Duration::from_millis(25))
.with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
"workspace-test",
base_url.clone(),
"runtime-test",
)
.with_runtime_request_source(&identity, base_url),
);
let error = client
.fetch_resource(BackendResourceFetchRequest {
audit_correlation_id: handle.audit_correlation_id.clone(),
handle,
runtime_id: "runtime-test".to_string(),
worker_id: None,
})
.await
.unwrap_err();
server.abort();
assert_eq!(error, BackendResourceError::Timeout);
}
#[test]
fn repository_ssh_access_secret_debug_redacts_all_secret_values() {
let secret = RepositorySshAccessSecret {
credential_candidates: vec![RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 2,
private_key: "PRIVATE KEY secret bytes".to_string(),
}],
known_hosts_entry: "host key secret bytes".to_string(),
};
let debug = format!("{secret:?}");
assert!(debug.contains("credential-1"));
assert!(!debug.contains("secret bytes"));
assert_eq!(debug.matches("[REDACTED]").count(), 2);
}
#[test]
fn response_verification_detects_digest_mismatch() {
let bytes = b"archive-bytes";
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
+116 -221
View File
@@ -1,16 +1,16 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use worker::{
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
};
use crate::auth::{
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind,
WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
WORKER_REMOVE_PERMISSION, WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION,
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
};
use crate::runtime::RuntimeWorkspaceScope;
use crate::worker_backend::WorkspacePromptProjectionCache;
@@ -133,9 +133,7 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
#[derive(Clone)]
enum RuntimeWorkerMutationTransport {
Remote {
base_url: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
request_client: RuntimeWorkspaceRequestClient,
},
Embedded {
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
@@ -155,17 +153,13 @@ impl RuntimeWorkerMutationForwarder {
identity: &RuntimeIdentityMaterial,
scope: RuntimeWorkspaceScope,
source_worker_id: impl Into<String>,
base_url: impl Into<String>,
request_client: RuntimeWorkspaceRequestClient,
) -> Self {
Self {
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
scope: scope.clone(),
source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Remote {
base_url: base_url.into().trim_end_matches('/').to_string(),
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
request_source_audience: scope.server_id,
},
transport: RuntimeWorkerMutationTransport::Remote { request_client },
}
}
@@ -201,18 +195,11 @@ impl RuntimeWorkerMutationForwarder {
)?;
match (&self.transport, proof) {
(
RuntimeWorkerMutationTransport::Remote {
base_url,
request_source_signer,
request_source_audience,
},
RuntimeWorkerMutationTransport::Remote { request_client },
RuntimeOwnedWorkerMutationProof::Remote(token),
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
base_url: base_url.clone(),
workspace_id: self.scope.workspace_id.clone(),
request_client: request_client.clone(),
source_worker_id: self.source_worker_id.clone(),
request_source_signer: request_source_signer.clone(),
request_source_audience: request_source_audience.clone(),
token,
target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(),
@@ -233,11 +220,8 @@ impl RuntimeWorkerMutationForwarder {
}
struct RemoteWorkerRemoveHttpRequest {
base_url: String,
workspace_id: String,
request_client: RuntimeWorkspaceRequestClient,
source_worker_id: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
token: String,
target_runtime_id: String,
target_worker_id: String,
@@ -270,54 +254,54 @@ fn execute_remote_worker_remove_http(
fn execute_remote_worker_remove_http_blocking(
request: RemoteWorkerRemoveHttpRequest,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let path = format!("/api/w/{}/workers/remove", request.workspace_id);
let url = format!("{}{}", request.base_url, path);
let body = serde_json::to_string(&serde_json::json!({
let path = format!(
"/api/w/{}/workers/remove",
request.request_client.workspace_id()
);
let body = serde_json::to_vec(&serde_json::json!({
"target_runtime_id": request.target_runtime_id,
"target_worker_id": request.target_worker_id,
"reason": request.reason,
}))
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let request_source_proof = request.request_source_signer.issue(
&request.request_source_audience,
&request.workspace_id,
Some(&request.source_worker_id),
WORKSPACE_REQUEST_PERMISSION,
"POST",
&path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)?;
let client = reqwest::blocking::Client::new();
let response = client
.post(url)
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof)
.header(
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
request.token,
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body)
.send()
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
reqwest::header::HeaderValue::from_str(&request.token)
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
);
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
let response = request
.request_client
.execute_blocking(RuntimeWorkspaceRequest {
method: reqwest::Method::POST,
path_and_query: path,
body,
headers,
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
worker_id: Some(request.source_worker_id),
timeout: Some(Duration::from_secs(5)),
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let status = response.status().as_u16();
let body = response
.text()
let body = String::from_utf8(response.body)
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
Ok(WorkspaceResponse { status, body })
Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
}
#[derive(Clone)]
pub struct RuntimeOwnedWorkspaceClient {
workspace_id: String,
base_url: String,
runtime_id: String,
request_client: RuntimeWorkspaceRequestClient,
worker_id: String,
request_timeout: Option<Duration>,
worker_remove: Option<RuntimeWorkerMutationForwarder>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
}
@@ -328,15 +312,32 @@ impl RuntimeOwnedWorkspaceClient {
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Self {
let workspace_id = workspace_id.into();
Self {
workspace_id: workspace_id.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
runtime_id: runtime_id.into(),
request_client: RuntimeWorkspaceRequestClient::new(
workspace_id.clone(),
base_url,
runtime_id,
),
workspace_id,
worker_id: worker_id.into(),
request_timeout: None,
worker_remove: None,
prompt_projection_cache: None,
}
}
pub(crate) fn from_request_client(
request_client: RuntimeWorkspaceRequestClient,
worker_id: impl Into<String>,
) -> Self {
let workspace_id = request_client.workspace_id().to_string();
Self {
workspace_id,
request_client,
worker_id: worker_id.into(),
request_timeout: None,
worker_remove: None,
request_source_signer: None,
request_source_audience: None,
prompt_projection_cache: None,
}
}
@@ -351,8 +352,9 @@ impl RuntimeOwnedWorkspaceClient {
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
self.request_source_audience = Some(audience.into());
self.request_client = self
.request_client
.with_runtime_request_source(identity, audience);
self
}
@@ -375,44 +377,43 @@ impl RuntimeOwnedWorkspaceClient {
request: WorkspaceRequest,
permission: &'static str,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone();
let workspace_id = self.workspace_id.clone();
let runtime_id = self.runtime_id.clone();
let worker_id = self.worker_id.clone();
let request_source_signer = self.request_source_signer.clone();
let request_source_audience = self.request_source_audience.clone();
let request_timeout = self.request_timeout;
if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(move || {
execute_runtime_owned_workspace_http(
&base_url,
&workspace_id,
&runtime_id,
&worker_id,
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout,
permission,
request,
)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.workspace_id,
&self.runtime_id,
&self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout,
permission,
request,
)
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
WorkspaceRequestMethod::Post => reqwest::Method::POST,
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
};
let body = request.body.unwrap_or_default().into_bytes();
let mut headers = reqwest::header::HeaderMap::new();
if !body.is_empty() {
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
}
let request_label = format!("{method} {}", request.path);
let response = self
.request_client
.execute_blocking(RuntimeWorkspaceRequest {
method,
path_and_query: request.path,
body,
headers,
permission: permission.to_string(),
worker_id: Some(self.worker_id.clone()),
timeout: self.request_timeout,
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| {
WorkspaceClientError::Request(format!("{request_label} failed: {error}"))
})?;
let body = String::from_utf8(response.body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
}
}
@@ -420,8 +421,8 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RuntimeOwnedWorkspaceClient")
.field("workspace_id", &self.workspace_id)
.field("base_url", &self.base_url)
.field("workspace_id", &self.request_client.workspace_id())
.field("base_url", &self.request_client.base_url())
.field("source", &"Runtime-owned")
.field(
"worker_remove",
@@ -433,7 +434,7 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some(&self.workspace_id)
Some(self.request_client.workspace_id())
}
fn kind(&self) -> &str {
@@ -588,117 +589,6 @@ fn percent_encode_query(value: &str) -> String {
encoded
}
fn execute_runtime_owned_workspace_http(
base_url: &str,
workspace_id: &str,
runtime_id: &str,
worker_id: &str,
request_source_signer: Option<&RuntimeRequestSourceSigner>,
request_source_audience: Option<&str>,
request_timeout: Option<Duration>,
permission: &'static str,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !request.path.starts_with('/') || request.path.starts_with("//") {
return Err(WorkspaceClientError::InvalidPath(request.path));
}
let url = format!("{base_url}{}", request.path);
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
WorkspaceRequestMethod::Post => reqwest::Method::POST,
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
};
let client = reqwest::blocking::Client::builder()
.timeout(request_timeout)
.build()
.map_err(|error| {
WorkspaceClientError::Unavailable(format!(
"failed to build Workspace API HTTP client: {}",
reqwest_error_chain(&error)
))
})?;
let request_label = format!("{method} {}", request.path);
let body = request.body.unwrap_or_default();
let mut request_builder = client
.request(method.clone(), url)
.header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id);
if let Some(signer) = request_source_signer {
let audience = request_source_audience.ok_or_else(|| {
WorkspaceClientError::Request(
"runtime request proof audience is unavailable".to_owned(),
)
})?;
let proof = signer
.issue(
audience,
workspace_id,
Some(worker_id),
permission,
method.as_str(),
&request.path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !body.is_empty() {
request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
let response = request_builder
.send()
.map_err(|error| workspace_http_error(&request_label, "waiting for response", error))?;
let status = response.status().as_u16();
let body = response
.text()
.map_err(|error| workspace_http_error(&request_label, "reading response body", error))?;
Ok(WorkspaceResponse { status, body })
}
fn workspace_http_error(
request_label: &str,
stage: &str,
error: reqwest::Error,
) -> WorkspaceClientError {
let details = reqwest_error_chain(&error);
if error.is_timeout() {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} timed out while {stage}: {details}"
))
} else if error.is_connect() {
WorkspaceClientError::Unavailable(format!(
"Workspace API {request_label} could not connect while {stage}: {details}"
))
} else {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} transport failed while {stage}: {details}"
))
}
}
fn reqwest_error_chain(error: &reqwest::Error) -> String {
let mut details = error.to_string();
let mut source = std::error::Error::source(error);
for _ in 0..4 {
let Some(current) = source else {
break;
};
let current_text = current.to_string();
if !current_text.is_empty() && !details.ends_with(&current_text) {
details.push_str(": ");
details.push_str(&current_text);
}
source = std::error::Error::source(current);
}
details
}
#[derive(Debug, thiserror::Error)]
pub enum RuntimeWorkerMutationForwardError {
#[error(transparent)]
@@ -722,9 +612,9 @@ fn unix_now_seconds() -> u64 {
mod tests {
use super::*;
use crate::auth::{
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
decode_worker_mutation_source_claims, request_body_digest,
verify_worker_mutation_source_proof,
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WorkerMutationSourceExpectation,
decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
request_body_digest, verify_worker_mutation_source_proof,
};
#[test]
@@ -1132,7 +1022,12 @@ mod tests {
&identity,
scope,
"worker-source",
format!("http://{address}"),
RuntimeWorkspaceRequestClient::new(
"workspace-a",
format!("http://{address}"),
"runtime-a",
)
.with_runtime_request_source(&identity, "server-a"),
);
let response = forwarder
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,360 @@
use std::error::Error as _;
use std::io::Read;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use futures::StreamExt;
use reqwest::header::HeaderMap;
use thiserror::Error;
use crate::auth::{
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
RuntimeRequestSourceSigner,
};
const DEFAULT_REQUEST_PROOF_TTL_SECONDS: u64 = 60;
const RUNTIME_ID_HEADER: &str = "x-yoi-runtime-id";
const WORKER_ID_HEADER: &str = "x-yoi-worker-id";
#[derive(Clone, Debug)]
pub struct RuntimeWorkspaceRequestClient {
workspace_id: String,
base_url: String,
runtime_id: String,
request_source: Option<(RuntimeRequestSourceSigner, String)>,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeWorkspaceRequest {
pub method: reqwest::Method,
pub path_and_query: String,
pub body: Vec<u8>,
pub headers: HeaderMap,
pub permission: String,
pub worker_id: Option<String>,
pub timeout: Option<Duration>,
pub max_response_bytes: usize,
}
#[derive(Debug)]
pub(crate) struct RuntimeWorkspaceResponse {
pub status: reqwest::StatusCode,
pub headers: HeaderMap,
pub body: Vec<u8>,
}
#[derive(Debug, Error)]
pub(crate) enum RuntimeWorkspaceRequestError {
#[error("invalid Workspace request: {0}")]
InvalidRequest(String),
#[error("failed to sign Workspace request: {0}")]
Sign(#[from] RuntimeAuthError),
#[error("Workspace request failed: {message}")]
Transport { message: String, timeout: bool },
#[error("Workspace response exceeded {max_response_bytes} bytes")]
ResponseTooLarge { max_response_bytes: usize },
}
impl RuntimeWorkspaceRequestError {
fn transport(error: reqwest::Error) -> Self {
let timeout = error.is_timeout();
Self::Transport {
message: reqwest_error_chain(&error),
timeout,
}
}
pub(crate) fn is_timeout(&self) -> bool {
matches!(self, Self::Transport { timeout: true, .. })
}
}
impl RuntimeWorkspaceRequestClient {
pub fn new(
workspace_id: impl Into<String>,
base_url: impl Into<String>,
runtime_id: impl Into<String>,
) -> Self {
Self {
workspace_id: workspace_id.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
runtime_id: runtime_id.into(),
request_source: None,
}
}
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
audience: impl Into<String>,
) -> Self {
self.request_source = Some((
RuntimeRequestSourceSigner::from_identity(identity),
audience.into(),
));
self
}
pub fn workspace_id(&self) -> &str {
&self.workspace_id
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
pub fn audience(&self) -> Option<&str> {
self.request_source
.as_ref()
.map(|(_, audience)| audience.as_str())
}
pub fn matches_workspace(&self, workspace_id: &str, base_url: &str) -> bool {
self.workspace_id == workspace_id
&& self.base_url.trim_end_matches('/') == base_url.trim_end_matches('/')
}
pub(crate) async fn execute(
&self,
request: RuntimeWorkspaceRequest,
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
let prepared = self.prepare(&request)?;
let mut client_builder = reqwest::Client::builder();
if let Some(timeout) = request.timeout {
client_builder = client_builder.timeout(timeout);
}
let client = client_builder
.build()
.map_err(RuntimeWorkspaceRequestError::transport)?;
let mut builder = client
.request(request.method, prepared.url)
.headers(request.headers)
.header(RUNTIME_ID_HEADER, &self.runtime_id);
if let Some(worker_id) = request.worker_id.as_deref() {
builder = builder.header(WORKER_ID_HEADER, worker_id);
}
if let Some(proof) = prepared.proof {
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !request.body.is_empty() {
builder = builder.body(request.body);
}
let response = builder
.send()
.await
.map_err(RuntimeWorkspaceRequestError::transport)?;
let status = response.status();
let headers = response.headers().clone();
if response
.content_length()
.is_some_and(|size| size > request.max_response_bytes as u64)
{
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(RuntimeWorkspaceRequestError::transport)?;
if body.len().saturating_add(chunk.len()) > request.max_response_bytes {
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
body.extend_from_slice(&chunk);
}
Ok(RuntimeWorkspaceResponse {
status,
headers,
body,
})
}
pub(crate) fn execute_blocking(
&self,
request: RuntimeWorkspaceRequest,
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
let client = self.clone();
std::thread::spawn(move || client.execute_blocking_inner(request))
.join()
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
message: "Workspace request thread panicked".to_string(),
timeout: false,
})?
}
fn execute_blocking_inner(
&self,
request: RuntimeWorkspaceRequest,
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
let prepared = self.prepare(&request)?;
let mut client_builder = reqwest::blocking::Client::builder();
if let Some(timeout) = request.timeout {
client_builder = client_builder.timeout(timeout);
}
let client = client_builder
.build()
.map_err(RuntimeWorkspaceRequestError::transport)?;
let mut builder = client
.request(request.method, prepared.url)
.headers(request.headers)
.header(RUNTIME_ID_HEADER, &self.runtime_id);
if let Some(worker_id) = request.worker_id.as_deref() {
builder = builder.header(WORKER_ID_HEADER, worker_id);
}
if let Some(proof) = prepared.proof {
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !request.body.is_empty() {
builder = builder.body(request.body);
}
let response = builder
.send()
.map_err(RuntimeWorkspaceRequestError::transport)?;
let status = response.status();
let headers = response.headers().clone();
if response
.content_length()
.is_some_and(|size| size > request.max_response_bytes as u64)
{
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
let limit = u64::try_from(request.max_response_bytes)
.unwrap_or(u64::MAX)
.saturating_add(1);
let mut body = Vec::new();
response
.take(limit)
.read_to_end(&mut body)
.map_err(|error| RuntimeWorkspaceRequestError::Transport {
message: error.to_string(),
timeout: false,
})?;
if body.len() > request.max_response_bytes {
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
max_response_bytes: request.max_response_bytes,
});
}
Ok(RuntimeWorkspaceResponse {
status,
headers,
body,
})
}
fn prepare(
&self,
request: &RuntimeWorkspaceRequest,
) -> Result<PreparedRuntimeWorkspaceRequest, RuntimeWorkspaceRequestError> {
if !request.path_and_query.starts_with('/') || request.path_and_query.starts_with("//") {
return Err(RuntimeWorkspaceRequestError::InvalidRequest(
"path must start with '/'".to_string(),
));
}
let url = reqwest::Url::parse(&format!("{}{}", self.base_url, request.path_and_query))
.map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))?;
let mut request_target = url.path().to_string();
if let Some(query) = url.query() {
request_target.push('?');
request_target.push_str(query);
}
let proof = self
.request_source
.as_ref()
.map(|(signer, audience)| {
signer.issue(
audience,
&self.workspace_id,
request.worker_id.as_deref(),
&request.permission,
request.method.as_str(),
&request_target,
&request.body,
unix_now_seconds(),
DEFAULT_REQUEST_PROOF_TTL_SECONDS,
)
})
.transpose()?;
Ok(PreparedRuntimeWorkspaceRequest { url, proof })
}
}
struct PreparedRuntimeWorkspaceRequest {
url: reqwest::Url,
proof: Option<String>,
}
fn reqwest_error_chain(error: &reqwest::Error) -> String {
let mut message = error.to_string();
let mut source = error.source();
while let Some(error) = source {
message.push_str(": ");
message.push_str(&error.to_string());
source = error.source();
}
message
}
fn unix_now_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::decode_runtime_request_source_claims;
#[test]
fn route_issues_workspace_scoped_request_proof() {
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let client = RuntimeWorkspaceRequestClient::new(
"workspace-a",
"https://workspace.example.test/",
"runtime-a",
)
.with_runtime_request_source(&identity, "workspace-server-a");
let request = RuntimeWorkspaceRequest {
method: reqwest::Method::GET,
path_and_query: "/api/w/workspace-a/runtime-config?profile=coder".to_string(),
body: Vec::new(),
headers: HeaderMap::new(),
permission: "backend.resource.fetch".to_string(),
worker_id: None,
timeout: Some(Duration::from_secs(5)),
max_response_bytes: 1024,
};
let prepared = client.prepare(&request).unwrap();
let claims = decode_runtime_request_source_claims(&prepared.proof.unwrap()).unwrap();
assert_eq!(claims.aud, "workspace-server-a");
assert_eq!(claims.workspace_id, "workspace-a");
assert_eq!(claims.worker_id, None);
assert_eq!(claims.method, "GET");
assert_eq!(
claims.path,
"/api/w/workspace-a/runtime-config?profile=coder"
);
}
#[test]
fn route_matches_only_its_workspace_and_backend() {
let client = RuntimeWorkspaceRequestClient::new(
"workspace-a",
"https://workspace.example.test/",
"runtime-a",
);
assert!(client.matches_workspace("workspace-a", "https://workspace.example.test"));
assert!(!client.matches_workspace("workspace-b", "https://workspace.example.test"));
assert!(!client.matches_workspace("workspace-a", "https://other.example.test"));
}
}
+1 -3
View File
@@ -20,6 +20,7 @@ protocol = { workspace = true, features = ["json-schema"] }
client = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
@@ -42,7 +43,6 @@ memory = { workspace = true }
uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true }
arc-swap = "1.9.1"
wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] }
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
futures-util = { version = "0.3", features = ["sink"] }
@@ -52,5 +52,3 @@ dotenv = "0.15.0"
futures = { workspace = true }
serial_test = "3.4.0"
tempfile = { workspace = true }
wat = "1.241.2"
yoi-plugin-pdk = { workspace = true }
+4 -1
View File
@@ -101,7 +101,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Send a run method
handle
.send(Method::run_text("What is the capital of France?"))
.send(Method::submit_text(
protocol::new_submission_request_id(),
"What is the capital of France?",
))
.await?;
// Wait for completion
+1
View File
@@ -1,6 +1,7 @@
pub(crate) mod metrics_tracker;
pub(crate) mod prune;
pub(crate) mod state;
pub(crate) mod telemetry;
pub(crate) mod token_counter;
pub(crate) mod usage_tracker;
pub(crate) mod worker;
+309 -140
View File
@@ -1,109 +1,236 @@
//! Shared state for compaction decisions.
//!
//! Holds the two configured thresholds and circuit-breaker / thrash-detection
//! flags shared between:
//! - `WorkerInterceptor` (reads `request_threshold` — the *safety net* for
//! between-requests yielding)
//! - `Worker::try_pre_run_compact` (reads `post_run_threshold` — the
//! *proactive* check before the next turn starts)
//! - `Worker::run()` / `resume()` (circuit breaker, thrash detection)
//!
//! Current occupancy (input-token count) is **not** stored here. The single
//! source of truth is `session_store::UsageRecord` (persisted per LLM call)
//! projected through `Worker::total_tokens()`. Callers pass the current
//! occupancy to `exceeds_*` at check time.
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use super::telemetry::CompactFailureCategory;
const MAX_COMPACT_FAILURES: usize = 3;
/// Process-local automatic compaction guard for the current logical run.
///
/// This guard is deliberately not persisted or reconstructed from session
/// history, compaction metrics, or replacement-segment state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactGuard {
Ready,
SuppressedForCurrentRun {
failure_category: CompactFailureCategory,
},
AwaitingPostCompactRequest,
}
/// Shared mutable state for compaction decisions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactionOutcome {
Succeeded,
Failed(CompactFailureCategory),
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactTrigger {
PreRun,
RequestThreshold,
}
/// Decision returned by an atomic threshold/attempt-state evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactDecision {
Continue,
Start(AutomaticCompactTrigger),
Block(AutomaticCompactBlock),
}
/// Typed reason why a provider request may not proceed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomaticCompactBlock {
/// An automatic attempt was already claimed for this logical run and has
/// not yet produced an outcome.
Attempted,
/// Compaction succeeded, but no post-compaction provider request has yet
/// committed a new occupancy UsageRecord.
Thrash,
/// This logical run already used its automatic attempt and it failed.
Failed(CompactFailureCategory),
/// This logical run's automatic attempt was cancelled. Cancellation is not
/// classified or counted as a compaction failure.
Cancelled,
}
#[derive(Debug)]
struct AutomaticCompactRuntimeState {
guard: AutomaticCompactGuard,
attempt_claimed: bool,
cancelled_attempt: bool,
pending_request_block: Option<AutomaticCompactBlock>,
}
/// Tracks automatic compaction thresholds and the current logical-run guard.
#[derive(Debug)]
pub(crate) struct CompactState {
/// Between-turns threshold (proactive). Checked before the next turn
/// starts. `None` disables the pre-run check.
post_run_threshold: Option<u64>,
/// Between-requests threshold (safety net). Checked inside a turn
/// before each LLM request. `None` disables the request check.
/// Proactive threshold checked before a fresh user run starts.
compact_threshold: Option<u64>,
/// Safety threshold checked immediately before every provider request.
request_threshold: Option<u64>,
/// Token budget retained verbatim at the tail after compaction.
retained_tokens: u64,
/// Consecutive compact failures. At `MAX_COMPACT_FAILURES`, compaction is disabled.
consecutive_failures: AtomicUsize,
/// `true` immediately after a successful compact, cleared on next normal completion.
just_compacted: AtomicBool,
/// `true` when circuit breaker has tripped.
disabled: AtomicBool,
runtime: Mutex<AutomaticCompactRuntimeState>,
}
impl CompactState {
pub(crate) fn new(
post_run_threshold: Option<u64>,
compact_threshold: Option<u64>,
request_threshold: Option<u64>,
retained_tokens: u64,
) -> Self {
Self {
post_run_threshold,
compact_threshold,
request_threshold,
retained_tokens,
consecutive_failures: AtomicUsize::new(0),
just_compacted: AtomicBool::new(false),
disabled: AtomicBool::new(false),
runtime: Mutex::new(AutomaticCompactRuntimeState {
guard: AutomaticCompactGuard::Ready,
attempt_claimed: false,
cancelled_attempt: false,
pending_request_block: None,
}),
}
}
/// Configured between-requests threshold (if any).
pub(crate) fn request_threshold(&self) -> Option<u64> {
self.request_threshold
}
/// Token budget retained verbatim at the tail after compaction.
pub(crate) fn retained_tokens(&self) -> u64 {
self.retained_tokens
}
/// Whether compaction has been disabled by the circuit breaker.
pub(crate) fn is_disabled(&self) -> bool {
self.disabled.load(Ordering::Relaxed)
}
/// Whether `current_tokens` exceeds the between-requests threshold.
/// Returns `false` when `request_threshold` is unset.
pub(crate) fn exceeds_request(&self, current_tokens: u64) -> bool {
self.request_threshold
.map(|t| current_tokens > t)
.unwrap_or(false)
}
/// Whether `current_tokens` exceeds the post-run threshold.
/// Returns `false` when `post_run_threshold` is unset.
pub(crate) fn exceeds_post_run(&self, current_tokens: u64) -> bool {
self.post_run_threshold
.map(|t| current_tokens > t)
.unwrap_or(false)
}
/// Whether a compact just completed (for thrash detection).
pub(crate) fn just_compacted(&self) -> bool {
self.just_compacted.load(Ordering::Relaxed)
}
/// Set or clear the just_compacted flag.
pub(crate) fn set_just_compacted(&self, val: bool) {
self.just_compacted.store(val, Ordering::Relaxed);
}
/// Record a successful compaction: reset failure counter, set just_compacted.
pub(crate) fn record_compact_success(&self) {
self.consecutive_failures.store(0, Ordering::Relaxed);
self.just_compacted.store(true, Ordering::Relaxed);
}
/// Record a compaction failure. Disables compaction after MAX_COMPACT_FAILURES.
pub(crate) fn record_compact_failure(&self) {
let prev = self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
if prev + 1 >= MAX_COMPACT_FAILURES {
self.disabled.store(true, Ordering::Relaxed);
pub(crate) fn pre_run_eligible(&self, total_tokens: u64) -> bool {
if !self
.compact_threshold
.is_some_and(|threshold| total_tokens > threshold)
{
return false;
}
let runtime = self.lock_runtime();
runtime.guard == AutomaticCompactGuard::Ready && !runtime.attempt_claimed
}
/// Starts a fresh logical run. Pause/resume paths must not call this.
pub(crate) fn begin_logical_run(&self) {
self.clear_logical_run();
}
/// Clears per-run state after a terminal run outcome.
pub(crate) fn finish_logical_run(&self) {
self.clear_logical_run();
}
/// Atomically evaluates the proactive threshold and claims this logical
/// run's automatic attempt when eligible.
pub(crate) fn evaluate_pre_run(&self, total_tokens: u64) -> AutomaticCompactDecision {
if !self
.compact_threshold
.is_some_and(|threshold| total_tokens > threshold)
{
return AutomaticCompactDecision::Continue;
}
self.claim_attempt(AutomaticCompactTrigger::PreRun)
}
/// Atomically evaluates the request safety threshold and either claims an
/// automatic attempt or returns the typed reason the request must stop.
pub(crate) fn evaluate_request(&self, total_tokens: u64) -> AutomaticCompactDecision {
if !self
.request_threshold
.is_some_and(|threshold| total_tokens > threshold)
{
return AutomaticCompactDecision::Continue;
}
self.claim_attempt(AutomaticCompactTrigger::RequestThreshold)
}
/// Claims a hook-originated compaction yield under the same guard used by
/// threshold evaluation. This exists even in manual-only configurations.
pub(crate) fn claim_hook_yield(&self) -> AutomaticCompactDecision {
self.claim_attempt(AutomaticCompactTrigger::RequestThreshold)
}
pub(crate) fn has_claimed_attempt(&self) -> bool {
self.lock_runtime().attempt_claimed
}
pub(crate) fn record_request_block(&self, block: AutomaticCompactBlock) {
self.lock_runtime().pending_request_block = Some(block);
}
/// Completes the currently claimed automatic attempt exactly once.
pub(crate) fn complete_automatic(&self, outcome: CompactionOutcome) -> bool {
let mut runtime = self.lock_runtime();
if !runtime.attempt_claimed
|| runtime.guard != AutomaticCompactGuard::Ready
|| runtime.cancelled_attempt
{
return false;
}
match outcome {
CompactionOutcome::Succeeded => {
runtime.guard = AutomaticCompactGuard::AwaitingPostCompactRequest;
}
CompactionOutcome::Failed(failure_category) => {
runtime.guard = AutomaticCompactGuard::SuppressedForCurrentRun { failure_category };
}
CompactionOutcome::Cancelled => {
runtime.cancelled_attempt = true;
}
}
true
}
/// Re-arms automatic compaction only after the first real provider request
/// following successful compaction has a durably committed UsageRecord.
pub(crate) fn post_compact_request_committed(&self) {
let mut runtime = self.lock_runtime();
if runtime.guard == AutomaticCompactGuard::AwaitingPostCompactRequest {
runtime.guard = AutomaticCompactGuard::Ready;
runtime.attempt_claimed = false;
runtime.cancelled_attempt = false;
}
}
pub(crate) fn take_pending_request_block(&self) -> Option<AutomaticCompactBlock> {
self.lock_runtime().pending_request_block.take()
}
fn claim_attempt(&self, trigger: AutomaticCompactTrigger) -> AutomaticCompactDecision {
let mut runtime = self.lock_runtime();
match runtime.guard {
AutomaticCompactGuard::Ready if !runtime.attempt_claimed => {
runtime.attempt_claimed = true;
AutomaticCompactDecision::Start(trigger)
}
AutomaticCompactGuard::Ready if runtime.cancelled_attempt => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Cancelled)
}
AutomaticCompactGuard::Ready => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
}
AutomaticCompactGuard::SuppressedForCurrentRun { failure_category } => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Failed(failure_category))
}
AutomaticCompactGuard::AwaitingPostCompactRequest => {
AutomaticCompactDecision::Block(AutomaticCompactBlock::Thrash)
}
}
}
fn clear_logical_run(&self) {
let mut runtime = self.lock_runtime();
runtime.guard = AutomaticCompactGuard::Ready;
runtime.attempt_claimed = false;
runtime.cancelled_attempt = false;
runtime.pending_request_block = None;
}
fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AutomaticCompactRuntimeState> {
self.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
pub(crate) fn guard(&self) -> AutomaticCompactGuard {
self.lock_runtime().guard
}
}
@@ -111,81 +238,123 @@ impl CompactState {
mod tests {
use super::*;
const FAILURE: CompactFailureCategory = CompactFailureCategory::Storage;
#[test]
fn both_thresholds_configured() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
assert_eq!(state.request_threshold(), Some(90_000));
assert_eq!(state.retained_tokens(), 8_000);
fn automatic_failure_suppresses_only_current_logical_run() {
let state = CompactState::new(Some(10), Some(10), 2);
assert_eq!(
state.evaluate_pre_run(11),
AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun)
);
assert!(state.complete_automatic(CompactionOutcome::Failed(FAILURE)));
assert_eq!(
state.guard(),
AutomaticCompactGuard::SuppressedForCurrentRun {
failure_category: FAILURE
}
);
assert_eq!(
state.evaluate_request(10),
AutomaticCompactDecision::Continue,
"a failed proactive compact still permits a request below the safety threshold"
);
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Failed(FAILURE))
);
assert!(!state.exceeds_request(70_000));
assert!(!state.exceeds_post_run(70_000));
assert!(!state.exceeds_request(85_000));
assert!(state.exceeds_post_run(85_000));
assert!(state.exceeds_request(95_000));
assert!(state.exceeds_post_run(95_000));
state.begin_logical_run();
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
assert_eq!(
state.evaluate_pre_run(11),
AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun)
);
}
#[test]
fn post_run_only() {
let state = CompactState::new(Some(80_000), None, 8_000);
// request check always false when threshold is None.
assert!(!state.exceeds_request(1_000_000));
assert!(state.exceeds_post_run(85_000));
fn claimed_attempt_cannot_be_started_twice() {
let state = CompactState::new(Some(10), Some(10), 2);
assert!(matches!(
state.evaluate_pre_run(11),
AutomaticCompactDecision::Start(_)
));
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
);
}
#[test]
fn request_only() {
let state = CompactState::new(None, Some(90_000), 8_000);
assert!(!state.exceeds_post_run(1_000_000));
assert!(state.exceeds_request(95_000));
fn cancellation_consumes_run_attempt_without_becoming_failure() {
let state = CompactState::new(None, Some(10), 2);
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
assert!(state.complete_automatic(CompactionOutcome::Cancelled));
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Cancelled)
);
state.begin_logical_run();
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
}
#[test]
fn both_none_disables_all_checks() {
let state = CompactState::new(None, None, 8_000);
assert!(!state.exceeds_request(1_000_000));
assert!(!state.exceeds_post_run(1_000_000));
fn successful_compaction_requires_committed_request_before_rearming() {
let state = CompactState::new(None, Some(10), 2);
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
assert!(state.complete_automatic(CompactionOutcome::Succeeded));
assert_eq!(
state.evaluate_request(11),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Thrash)
);
state.post_compact_request_committed();
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
}
#[test]
fn circuit_breaker_trips_after_max_failures() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
assert!(!state.is_disabled());
fn pause_resume_preserves_guard_while_terminal_finish_clears_it() {
let state = CompactState::new(None, Some(10), 2);
assert!(matches!(
state.evaluate_request(11),
AutomaticCompactDecision::Start(_)
));
assert!(state.complete_automatic(CompactionOutcome::Failed(FAILURE)));
// Pause/resume deliberately performs no state transition.
assert!(matches!(
state.guard(),
AutomaticCompactGuard::SuppressedForCurrentRun { .. }
));
state.record_compact_failure();
assert!(!state.is_disabled());
state.record_compact_failure();
assert!(!state.is_disabled());
state.record_compact_failure();
assert!(state.is_disabled());
state.finish_logical_run();
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
}
#[test]
fn success_resets_failure_count() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
state.record_compact_failure();
state.record_compact_failure();
assert!(!state.is_disabled());
state.record_compact_success();
assert!(state.just_compacted());
state.record_compact_failure();
state.record_compact_failure();
assert!(!state.is_disabled());
}
#[test]
fn just_compacted_lifecycle() {
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
assert!(!state.just_compacted());
state.record_compact_success();
assert!(state.just_compacted());
state.set_just_compacted(false);
assert!(!state.just_compacted());
fn hook_yield_is_guarded_without_threshold_configuration() {
let state = CompactState::new(None, None, 2);
assert!(matches!(
state.claim_hook_yield(),
AutomaticCompactDecision::Start(_)
));
assert_eq!(
state.claim_hook_yield(),
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
);
}
}
+441
View File
@@ -0,0 +1,441 @@
use std::collections::BTreeMap;
use std::time::Duration;
use agen::token_counter::EstimateSource;
use agen::usage_record::UsageRecord;
use session_metrics::Metric;
use session_store::{SegmentId, SessionId};
use super::usage_tracker::{PostRequestMetric, UsageSnapshot};
const MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactMode {
Manual,
Automatic,
}
impl CompactMode {
fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Automatic => "automatic",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactThresholdPolicy {
Manual,
PreRun,
RequestThreshold,
}
impl CompactThresholdPolicy {
fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::PreRun => "pre_run",
Self::RequestThreshold => "request_threshold",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactFailureCategory {
Cancelled,
SummaryMissing,
SummaryTooLarge,
ResultContextTooLarge,
ActiveSegmentCommit,
Storage,
InternalWorker,
Preparation,
Other,
}
impl CompactFailureCategory {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Cancelled => "cancelled",
Self::SummaryMissing => "summary_missing",
Self::SummaryTooLarge => "summary_too_large",
Self::ResultContextTooLarge => "result_context_too_large",
Self::ActiveSegmentCommit => "active_segment_commit",
Self::Storage => "storage",
Self::InternalWorker => "internal_worker",
Self::Preparation => "preparation",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct CompactAttempt {
correlation_id: String,
session_id: SessionId,
source_segment_id: SegmentId,
mode: CompactMode,
threshold_policy: CompactThresholdPolicy,
pre_context_tokens: u64,
pre_context_source: EstimateSource,
retained_token_budget: u64,
}
impl CompactAttempt {
pub(crate) fn new(
correlation_id: String,
session_id: SessionId,
source_segment_id: SegmentId,
mode: CompactMode,
threshold_policy: CompactThresholdPolicy,
pre_context_tokens: u64,
pre_context_source: EstimateSource,
retained_token_budget: u64,
) -> Self {
debug_assert!(uuid::Uuid::parse_str(&correlation_id).is_ok());
Self {
correlation_id,
session_id,
source_segment_id,
mode,
threshold_policy,
pre_context_tokens,
pre_context_source,
retained_token_budget,
}
}
pub(crate) fn correlation_id(&self) -> &str {
&self.correlation_id
}
pub(crate) fn start_metric(&self) -> Metric {
self.metric("compact.start")
.with_value(safe_metric_number(self.pre_context_tokens))
.with_dimension("occupancy_source", estimate_source(self.pre_context_source))
.with_dimension(
"retained_token_budget",
self.retained_token_budget.to_string(),
)
}
pub(crate) fn success_metrics(
&self,
result_segment_id: SegmentId,
elapsed: Duration,
stats: &CompactSuccessStats,
) -> Vec<Metric> {
let dimensions = self.base_dimensions();
let correlation_id = self.correlation_id.clone();
let mut metrics = vec![
metric_with_context("compact.finish", 1, &dimensions, &correlation_id)
.with_dimension("outcome", "succeeded")
.with_dimension("result_segment_id", result_segment_id.to_string())
.with_dimension("retained_items", stats.retained_items.to_string())
.with_dimension("summarized_items", stats.summarized_items.to_string()),
metric_with_context(
"compact.retained_tokens",
stats.retained_tokens,
&dimensions,
&correlation_id,
)
.with_dimension("source", estimate_source(stats.retained_tokens_source)),
metric_with_context(
"compact.overview_tokens",
stats.overview_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.summary_tokens",
stats.summary_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.auto_read_tokens",
stats.auto_read_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.result_context_tokens",
stats.result_context_tokens,
&dimensions,
&correlation_id,
)
.with_dimension("source", estimate_source(stats.result_context_source)),
metric_with_context(
"compact.input_tokens",
stats.usage.input_total_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.output_tokens",
stats.usage.output_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.cache_read_tokens",
stats.usage.cache_read_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.cache_creation_tokens",
stats.usage.cache_write_tokens,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.requests",
stats.requests,
&dimensions,
&correlation_id,
),
metric_with_context("compact.turns", stats.turns, &dimensions, &correlation_id),
metric_with_context(
"compact.tool_calls",
stats.tool_calls,
&dimensions,
&correlation_id,
),
metric_with_context(
"compact.duration_ms",
elapsed.as_millis().min(u128::from(MAX_SAFE_INTEGER)) as u64,
&dimensions,
&correlation_id,
),
];
for metric in &mut metrics {
metric
.dimensions
.insert("result_segment_id".into(), result_segment_id.to_string());
}
// Provider UsageEvent currently carries tokens but no price or cost. Keep
// the field explicit and valueless rather than fabricating a zero cost.
metrics.push(
self.metric("compact.cost_usd")
.with_dimension("status", "unavailable")
.with_dimension("reason", "provider_usage_unpriced")
.with_dimension("result_segment_id", result_segment_id.to_string()),
);
metrics
}
pub(crate) fn failure_metrics(
&self,
observed_segment_id: SegmentId,
elapsed: Duration,
category: CompactFailureCategory,
) -> [Metric; 2] {
let outcome = if category == CompactFailureCategory::Cancelled {
"cancelled"
} else {
"failed"
};
let outcome_metric = self
.metric("compact.finish")
.with_value(1.0)
.with_dimension("outcome", outcome)
.with_dimension("failure_category", category.as_str())
.with_dimension("observed_segment_id", observed_segment_id.to_string());
let duration_metric = self
.metric("compact.duration_ms")
.with_value(elapsed.as_millis().min(u128::from(MAX_SAFE_INTEGER)) as f64)
.with_dimension("outcome", outcome)
.with_dimension("observed_segment_id", observed_segment_id.to_string());
[outcome_metric, duration_metric]
}
fn metric(&self, name: &'static str) -> Metric {
let mut metric = Metric::now(name).with_correlation_id(&self.correlation_id);
metric.dimensions = self.base_dimensions();
metric
}
fn base_dimensions(&self) -> BTreeMap<String, String> {
BTreeMap::from([
("session_id".into(), self.session_id.to_string()),
(
"source_segment_id".into(),
self.source_segment_id.to_string(),
),
("mode".into(), self.mode.as_str().into()),
("trigger".into(), self.threshold_policy.as_str().into()),
(
"threshold_policy".into(),
self.threshold_policy.as_str().into(),
),
])
}
}
#[derive(Debug, Clone)]
pub(crate) struct CompactSuccessStats {
pub(crate) retained_items: u64,
pub(crate) summarized_items: u64,
pub(crate) retained_tokens: u64,
pub(crate) retained_tokens_source: EstimateSource,
pub(crate) overview_tokens: u64,
pub(crate) summary_tokens: u64,
pub(crate) auto_read_tokens: u64,
pub(crate) result_context_tokens: u64,
pub(crate) result_context_source: EstimateSource,
pub(crate) usage: UsageSnapshot,
pub(crate) requests: u64,
pub(crate) turns: u64,
pub(crate) tool_calls: u64,
}
fn metric_with_context(
name: &'static str,
value: u64,
dimensions: &BTreeMap<String, String>,
correlation_id: &str,
) -> Metric {
let mut metric = Metric::now(name)
.with_value(safe_metric_number(value))
.with_correlation_id(correlation_id);
metric.dimensions = dimensions.clone();
metric
}
pub(crate) fn new_compact_metric_correlation_id(lifecycle_id: &str) -> String {
loop {
let correlation_id = uuid::Uuid::now_v7().to_string();
if correlation_id != lifecycle_id {
return correlation_id;
}
}
}
pub(crate) fn correlated_post_request_metric(
kind: PostRequestMetric,
correlation_id: &str,
record: &UsageRecord,
) -> Metric {
let value = match kind {
PostRequestMetric::Prune => record.cache_read_tokens,
PostRequestMetric::Compaction => record.input_total_tokens,
};
Metric::now(kind.name())
.with_correlation_id(correlation_id)
.with_value(safe_metric_number(value))
.with_dimension("history_len", record.history_len.to_string())
.with_dimension("input_total_tokens", record.input_total_tokens.to_string())
.with_dimension("cache_read_tokens", record.cache_read_tokens.to_string())
.with_dimension("cache_write_tokens", record.cache_write_tokens.to_string())
.with_dimension("output_tokens", record.output_tokens.to_string())
}
pub(crate) fn safe_metric_number(value: u64) -> f64 {
value.min(MAX_SAFE_INTEGER) as f64
}
fn estimate_source(source: EstimateSource) -> &'static str {
match source {
EstimateSource::Measured => "provider",
EstimateSource::Interpolated | EstimateSource::Extrapolated | EstimateSource::NoData => {
"fallback"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_metrics_use_fixed_bounded_labels_and_safe_numbers() {
let attempt = CompactAttempt::new(
uuid::Uuid::now_v7().to_string(),
uuid::Uuid::now_v7(),
uuid::Uuid::now_v7(),
CompactMode::Automatic,
CompactThresholdPolicy::RequestThreshold,
u64::MAX,
EstimateSource::Measured,
500,
);
let start = attempt.start_metric();
assert_eq!(start.name, "compact.start");
assert_eq!(start.value, Some(MAX_SAFE_INTEGER as f64));
assert_eq!(start.dimensions["mode"], "automatic");
assert_eq!(start.dimensions["trigger"], "request_threshold");
assert_eq!(start.dimensions["threshold_policy"], "request_threshold");
assert_eq!(start.dimensions["occupancy_source"], "provider");
assert!(start.correlation_id.is_some());
assert!(start.dimensions.keys().all(|key| key.len() <= 32));
assert!(start.dimensions.values().all(|value| value.len() <= 64));
}
#[test]
fn occupancy_sources_match_the_public_provider_fallback_schema() {
assert_eq!(estimate_source(EstimateSource::Measured), "provider");
assert_eq!(estimate_source(EstimateSource::Interpolated), "fallback");
assert_eq!(estimate_source(EstimateSource::Extrapolated), "fallback");
assert_eq!(estimate_source(EstimateSource::NoData), "fallback");
}
#[test]
fn metric_correlation_id_is_distinct_from_lifecycle_identity() {
let lifecycle_id = uuid::Uuid::now_v7().to_string();
let correlation_id = new_compact_metric_correlation_id(&lifecycle_id);
assert_ne!(correlation_id, lifecycle_id);
assert!(uuid::Uuid::parse_str(&correlation_id).is_ok());
}
#[test]
fn post_request_metric_saturates_values_above_json_safe_integer() {
let record = UsageRecord {
history_len: 1,
input_total_tokens: u64::MAX,
cache_read_tokens: 0,
cache_write_tokens: 0,
output_tokens: 1,
};
let metric = correlated_post_request_metric(
PostRequestMetric::Compaction,
"018f6f8a-9822-7b11-8b35-706f30313700",
&record,
);
assert_eq!(metric.name, "compact.post_request");
assert_eq!(metric.value, Some(MAX_SAFE_INTEGER as f64));
assert_eq!(
metric.dimensions["input_total_tokens"],
u64::MAX.to_string()
);
}
#[test]
fn failure_metrics_never_include_error_text() {
let attempt = CompactAttempt::new(
uuid::Uuid::now_v7().to_string(),
uuid::Uuid::now_v7(),
uuid::Uuid::now_v7(),
CompactMode::Manual,
CompactThresholdPolicy::Manual,
1,
EstimateSource::NoData,
1,
);
let [metric, duration] = attempt.failure_metrics(
uuid::Uuid::now_v7(),
Duration::from_millis(7),
CompactFailureCategory::InternalWorker,
);
let encoded = serde_json::to_string(&metric).unwrap();
assert_eq!(metric.value, Some(1.0));
assert_eq!(metric.dimensions["outcome"], "failed");
assert_eq!(duration.name, "compact.duration_ms");
assert_eq!(duration.value, Some(7.0));
assert!(encoded.contains("internal_worker"));
assert!(!encoded.contains("error"));
assert!(!encoded.contains("path"));
}
}
+76 -25
View File
@@ -19,14 +19,41 @@ use std::sync::Mutex;
use agen::UsageRecord;
use agen::timeline::event::UsageEvent;
/// One drained measurement: the underlying `UsageRecord` plus an optional
/// `correlation_id` stamped by the prune projection (or any other future
/// upstream observer) so that downstream metrics emitted alongside this
/// record can be joined to it after the fact.
/// The metric emitted after the next measured provider request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PostRequestMetric {
Prune,
Compaction,
}
impl PostRequestMetric {
pub(crate) fn name(self) -> &'static str {
match self {
Self::Prune => "prune.post_request",
Self::Compaction => "compact.post_request",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct PostRequestLink {
pub(crate) correlation_id: String,
pub(crate) metric: PostRequestMetric,
}
/// One drained measurement and its causal metric links.
#[derive(Debug, Clone)]
pub(crate) struct RecordedUsage {
pub(crate) record: UsageRecord,
pub(crate) correlation_id: Option<String>,
pub(crate) post_requests: Vec<PostRequestLink>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct UsageSnapshot {
pub(crate) input_total_tokens: u64,
pub(crate) cache_read_tokens: u64,
pub(crate) cache_write_tokens: u64,
pub(crate) output_tokens: u64,
}
/// Shared between the pre-request hook, the `on_usage` callback, and Worker.
@@ -34,11 +61,8 @@ pub(crate) struct UsageTracker {
/// `history.len()` captured at the most recent `pre_llm_request`.
/// Cleared when paired with an incoming `on_usage` event.
pending_history_len: Mutex<Option<usize>>,
/// Optional `correlation_id` set by an upstream observer (currently
/// the prune projection on `Fired`). Paired into the next
/// `RecordedUsage` and cleared. Skips that don't fire leave this
/// `None`, so the resulting record carries no correlation.
pending_correlation_id: Mutex<Option<String>>,
/// Optional causal link consumed by the next measured request.
pending_correlations: Mutex<Vec<PostRequestLink>>,
/// Records accumulated during the current run; drained by Worker.
pending_records: Mutex<Vec<RecordedUsage>>,
}
@@ -47,7 +71,7 @@ impl UsageTracker {
pub(crate) fn new() -> Self {
Self {
pending_history_len: Mutex::new(None),
pending_correlation_id: Mutex::new(None),
pending_correlations: Mutex::new(Vec::new()),
pending_records: Mutex::new(Vec::new()),
}
}
@@ -57,16 +81,23 @@ impl UsageTracker {
*self.pending_history_len.lock().unwrap() = Some(history_len);
}
/// Stash a `correlation_id` to be paired into the next `RecordedUsage`.
/// Currently invoked by the prune observer on `Fired` so that the
/// `prune.fire` metric and the `prune.post_request` metric (emitted
/// alongside the resulting `LlmUsage`) carry the same join key.
///
/// Overwrites any previous unconsumed value — by construction the
/// observer fires at most once per outgoing LLM request, immediately
/// before the pre-request hook captures `history_len`.
/// Pair a prune event with the next provider request.
pub(crate) fn note_correlation_id(&self, id: String) {
*self.pending_correlation_id.lock().unwrap() = Some(id);
self.note_post_request(id, PostRequestMetric::Prune);
}
/// Pair a completed compaction with the next normal provider request.
pub(crate) fn note_compaction_correlation_id(&self, id: String) {
self.note_post_request(id, PostRequestMetric::Compaction);
}
fn note_post_request(&self, id: String, metric: PostRequestMetric) {
let mut pending = self.pending_correlations.lock().unwrap();
pending.retain(|link| link.metric != metric);
pending.push(PostRequestLink {
correlation_id: id,
metric,
});
}
/// Called from the `on_usage` callback with the aggregated final
@@ -79,7 +110,7 @@ impl UsageTracker {
Some(n) => n,
None => return,
};
let correlation_id = self.pending_correlation_id.lock().unwrap().take();
let post_requests = std::mem::take(&mut *self.pending_correlations.lock().unwrap());
// UsageEvent.input_tokens は scheme 層で「占有量(プロンプト全長)」に
// 正規化済みである前提(Anthropic は cache_read + cache_creation を
// 加算して emit する)。
@@ -95,7 +126,7 @@ impl UsageTracker {
cache_write_tokens: cache_write,
output_tokens: output,
},
correlation_id,
post_requests,
});
}
@@ -145,7 +176,7 @@ mod tests {
assert_eq!(records[0].record.cache_read_tokens, 800);
assert_eq!(records[0].record.cache_write_tokens, 100);
assert_eq!(records[0].record.output_tokens, 42);
assert!(records[0].correlation_id.is_none());
assert!(records[0].post_requests.is_empty());
}
#[test]
@@ -192,6 +223,24 @@ mod tests {
assert_eq!(records[1].record.cache_read_tokens, 50);
}
#[test]
fn prune_and_compaction_links_share_the_next_request() {
let tracker = UsageTracker::new();
tracker.note_compaction_correlation_id("compact-id".into());
tracker.note_correlation_id("prune-id".into());
tracker.note_request(5);
tracker.record_usage(&make_event(100, 10, 2, 20));
let records = tracker.drain();
assert_eq!(records[0].post_requests.len(), 2);
assert!(records[0].post_requests.iter().any(|link| {
link.correlation_id == "compact-id" && link.metric == PostRequestMetric::Compaction
}));
assert!(records[0].post_requests.iter().any(|link| {
link.correlation_id == "prune-id" && link.metric == PostRequestMetric::Prune
}));
}
#[test]
fn correlation_id_pairs_with_next_record_only() {
let tracker = UsageTracker::new();
@@ -205,7 +254,9 @@ mod tests {
let records = tracker.drain();
assert_eq!(records.len(), 2);
assert_eq!(records[0].correlation_id.as_deref(), Some("abc"));
assert!(records[1].correlation_id.is_none());
assert_eq!(records[0].post_requests.len(), 1);
assert_eq!(records[0].post_requests[0].correlation_id, "abc");
assert_eq!(records[0].post_requests[0].metric, PostRequestMetric::Prune);
assert!(records[1].post_requests.is_empty());
}
}
+78 -19
View File
@@ -22,7 +22,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use agen::Item;
use agen::interceptor::{Interceptor, PreRequestAction, PreToolAction, ToolCallInfo};
use agen::interceptor::{
Interceptor, InterceptorResult, PreLlmRequestContext, PreRequestAction, PreToolAction,
ToolCallInfo,
};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
use async_trait::async_trait;
use serde::Deserialize;
@@ -397,15 +400,19 @@ impl CompactWorkerInterceptor {
}
#[async_trait]
impl Interceptor for CompactWorkerInterceptor {
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
let context = context.items;
let records = self.usage_tracker.records();
let estimate = agen::token_counter::total_tokens(context, &records);
if estimate.tokens > self.max_input_tokens {
return PreRequestAction::Cancel(format!(
return Ok(PreRequestAction::Cancel(format!(
"compact worker input occupancy exceeded {} tokens",
self.max_input_tokens
));
)));
}
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
@@ -413,25 +420,28 @@ impl Interceptor for CompactWorkerInterceptor {
.store(remaining, Ordering::Release);
if let Some(item) = self.maybe_emit_warning(remaining) {
self.usage_tracker.note_request(context.len() + 1);
return PreRequestAction::ContinueWith(vec![item]);
return Ok(PreRequestAction::ContinueWith(vec![item]));
}
self.usage_tracker.note_request(context.len());
PreRequestAction::Continue
Ok(PreRequestAction::Continue)
}
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, A>,
) -> InterceptorResult<PreToolAction> {
if self.final_reserve_tokens == 0 || info.call.name == "write_summary" {
return PreToolAction::Continue;
return Ok(PreToolAction::Continue);
}
let remaining = self.last_remaining_tokens.load(Ordering::Acquire);
if remaining > self.final_reserve_tokens {
return PreToolAction::Continue;
return Ok(PreToolAction::Continue);
}
PreToolAction::SyntheticResult(ToolResult::error(
Ok(PreToolAction::SyntheticResult(ToolResult::error(
info.call.id.clone(),
"compact worker final reserve reached; do not perform more exploratory tool reads. Call `write_summary` now.",
))
)))
}
}
@@ -467,13 +477,27 @@ mod tests {
let mut context = vec![Item::user_message("hello")];
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
@@ -481,7 +505,14 @@ mod tests {
// Two 100-token requests would exceed a cumulative 150-token cap, but
// current occupancy is still the latest 100-token measurement.
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
}
@@ -503,13 +534,27 @@ mod tests {
let mut context = vec![Item::user_message("hello")];
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::ContinueWith(items)
if items.len() == 1 && items[0].as_text().unwrap_or_default().contains("write_summary")
));
@@ -523,13 +568,27 @@ mod tests {
let mut context = vec![Item::user_message("hello")];
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Cancel(message) if message.contains("occupancy")
));
}
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(),
permission,
recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
})
})
.collect();
@@ -779,10 +780,10 @@ async fn probe_socket(socket_path: &Path) -> LiveInfo {
loop {
match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
Ok(Ok(Some(Event::Snapshot {
status: snapshot_status,
state: snapshot_state,
..
}))) => {
status = Some(snapshot_status);
status = Some(snapshot_state.catalog_status());
break;
}
Ok(Ok(Some(Event::Alert(_)))) => continue,
@@ -1012,7 +1013,19 @@ async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()>
}
async fn send_notify(socket_path: &Path, message: String, auto_run: bool) -> io::Result<()> {
connect_and_send(socket_path, &Method::Notify { message, auto_run }).await
let notification_request_id = protocol::new_submission_request_id();
connect_and_send(
socket_path,
&Method::NotifyTracked {
notification_request_id: notification_request_id.clone(),
message,
auto_run,
source: protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id,
},
},
)
.await
}
fn json_content<T: Serialize>(value: &T) -> Result<String, ToolError> {
@@ -1312,6 +1325,7 @@ mod tests {
target: root.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}],
active_child_segment,
)
@@ -1482,6 +1496,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1494,7 +1509,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
@@ -1517,6 +1532,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1529,14 +1545,17 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
.await
.unwrap();
let method = reader.next::<Method>().await.unwrap().unwrap();
if let Method::Notify { message, auto_run } = method {
if let Method::NotifyTracked {
message, auto_run, ..
} = method
{
assert!(auto_run);
tx.send(message).await.unwrap();
} else {
@@ -1608,6 +1627,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1620,7 +1640,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
@@ -1634,6 +1654,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1646,14 +1667,17 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
.await
.unwrap();
let method = reader.next::<Method>().await.unwrap().unwrap();
if let Method::Notify { message, auto_run } = method {
if let Method::NotifyTracked {
message, auto_run, ..
} = method
{
assert!(!auto_run);
tx.send(message).await.unwrap();
} else {
@@ -1738,6 +1762,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1750,7 +1775,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Paused,
state: WorkerStatus::Paused.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
@@ -1772,6 +1797,7 @@ mod tests {
target: PathBuf::from("/tmp"),
permission: "read".into(),
recursive: true,
symlink_policy: Default::default(),
}],
callback_address: PathBuf::from("/tmp/parent.sock"),
}
@@ -1790,6 +1816,8 @@ mod tests {
let _ = writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(
),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1802,7 +1830,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
+4
View File
@@ -286,6 +286,7 @@ fn read_rule(target: PathBuf) -> ScopeRule {
target,
permission: Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}
}
@@ -294,6 +295,7 @@ fn write_rule(target: PathBuf) -> ScopeRule {
target,
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}
}
@@ -306,6 +308,7 @@ fn workspace_scope(
target: workspace_root.to_path_buf(),
permission,
recursive: true,
symlink_policy: Default::default(),
};
let deny = deny_write
.iter()
@@ -711,6 +714,7 @@ permission = "write"
target: target.to_path_buf(),
permission,
recursive: true,
symlink_policy: Default::default(),
}
}

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