186 Commits
Author SHA1 Message Date
Hare 96592b1a24 fix: project Worker delete persistence diagnostics 2026-09-17 04:16:33 +09:00
Hare d70dee77a9 fix: retain Worker identity after delete persistence failure 2026-09-17 04:13:45 +09:00
Hare 1402240ef0 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-612-persisted-worker-identity 2026-09-17 03:54:59 +09:00
Hare decd1311cb fix: recover interrupted Runtime store migrations 2026-09-17 03:47:26 +09:00
Hare c4622e9e3a refactor: separate Worker identity from execution state 2026-09-17 03:25:40 +09:00
Hare 360387c986 fix: classify file content conflicts as retryable 2026-09-17 02:54:37 +09:00
Hare bc0342b03e fix: discard legacy top-level run generation 2026-09-17 02:07:55 +09:00
Hare 10ebac142e refactor: make worker state snapshots authoritative 2026-09-17 02:02:51 +09:00
Hare d6bef5d1c7 fix: address lifecycle review findings 2026-09-17 00:36:08 +09:00
Hare 9d8e0114f5 fix: serialize runtime worker lifecycle 2026-09-17 00:14:33 +09:00
Hare 93070a3a93 fix(runtime): reject duplicate removal revisions 2026-09-16 21:04:30 +09:00
Hare d1b94625b1 fix(runtime): fence removed internal worker activity 2026-09-16 20:57:17 +09:00
Hare fbbea1bf91 fix: scope compaction suppression to one run 2026-09-16 07:27:21 +09:00
Hare a0f8ee51a4 fix: guard automatic compaction retries 2026-09-16 07:16:31 +09:00
Hare ffc1933f06 fix: separate compaction metric correlation 2026-09-16 06:34:15 +09:00
Hare b6960878a6 fix: align compaction metric schema 2026-09-16 06:24:45 +09:00
Hare fef3b6f4a0 fix: preserve compaction metric precision and CAS category 2026-09-16 06:13:32 +09:00
Hare c0a73c12ec feat: record compaction lifecycle metrics 2026-09-16 06:01:04 +09:00
Hare f40714efc2 feat: add explicit session metrics export 2026-09-16 06:00:57 +09:00
Hare 07ed908ae6 test: cover compaction restart boundaries 2026-09-16 05:14:16 +09:00
Hare cfe772ea7d fix: fence compaction commit and status 2026-09-16 04:58:38 +09:00
Hare c0fe20e8a2 fix: serialize compaction pointer commits 2026-09-16 04:41:27 +09:00
Hare df22526a0d fix: render compaction on worker status 2026-09-16 04:25:37 +09:00
Hare 1d06d4b08a fix: keep compaction lifecycle runtime-only 2026-09-16 04:19:02 +09:00
Hare 4e7a314a00 fix: make compaction activation atomic 2026-09-16 03:44:48 +09:00
Hare 016dbd7cb1 fix: preserve worker run generations across restore 2026-09-15 01:17:30 +09:00
Hare 7210d3c202 fix: reject failed worker restores before attach 2026-09-15 00:47:12 +09:00
Hare 572204b49a fix: resolve CLI connections before async runtime 2026-09-15 00:28:25 +09:00
Hare 86dd67a24c fix: skip stopped worker protocol subscriptions 2026-09-14 23:31:14 +09:00
Hare beeba1fdfc fix: migrate legacy worker snapshots 2026-09-14 22:28:01 +09:00
Hare 41b7b289d0 fix: serialize Workdir lease admission with writes 2026-09-14 20:32:21 +09:00
Hare 24237249d1 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 20:05:34 +09:00
Hare e448073b98 fix: fence resolved Workdir lease aliases 2026-09-14 20:05:27 +09:00
Hare c08152d069 fix: share workspace creation wire contract 2026-09-14 19:45:18 +09:00
Hare 3995561220 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-468-remove-local-workspace-authority 2026-09-14 19:26:44 +09:00
Hare aea51caeb4 fix: resolve workspaces through backend repositories 2026-09-14 19:24:44 +09:00
Hare 3eca380bd8 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 19:12:17 +09:00
Hare 8a3e06bc81 feat: add selective Workdir symlink policies 2026-09-14 19:09:12 +09:00
Hare c4274c42cb fix: align plugin help with offline commands 2026-09-14 18:52:00 +09:00
Hare a61ad15767 refactor: remove ambient plugin authority 2026-09-14 18:40:42 +09:00
Hare 7f1e374fd7 fix: remove repository-local workspace selection 2026-09-14 18:31:04 +09:00
Hare e3f5445a02 fix: move workspace metadata authority to server database 2026-09-14 18:31:04 +09:00
Hare d2cb50d081 fix: fence runtime removal races and retries 2026-09-13 02:54:01 +09:00
Hare 6c609808c9 fix: use authoritative runtime removal result 2026-09-13 02:30:24 +09:00
Hare 2d4c7b383a feat: add guarded runtime removal operation 2026-09-13 02:30:15 +09:00
Hare c21ed7dff2 fix: preserve credential candidates across Workdir retries 2026-09-13 01:24:26 +09:00
Hare 448e392a0e fix: persist Workdir credential candidate snapshots 2026-09-13 01:14:39 +09:00
Hare d97c40d6af fix: reject plain HTTP repository sources 2026-09-13 01:12:01 +09:00
Hare 2d512b6be6 fix: remove stale HTTP repository access branch 2026-09-13 01:04:46 +09:00
Hare f061a95b48 fix: validate repository URI before source kind 2026-09-13 00:45:43 +09:00
Hare eefdef1bef fix: lock verified runtime public keys 2026-09-13 00:32:44 +09:00
Hare 7675f81999 fix: reject plain HTTP repository sources 2026-09-13 00:29:16 +09:00
Hare 8fb592071f fix: fence stale repository SSH probes 2026-09-13 00:27:32 +09:00
Hare 2528312142 fix: lock verified Runtime public keys in settings 2026-09-13 00:23:25 +09:00
Hare 08d7965ea8 fix: centralize repository access expiry 2026-09-13 00:20:25 +09:00
Hare e0badad91f fix: invalidate stale repository SSH probes 2026-09-13 00:17:12 +09:00
Hare f0a91ce2d8 fix: centralize repository access expiry 2026-09-13 00:11:03 +09:00
Hare 24cab83f48 test: cover repository credential rotate visibility 2026-09-13 00:05:14 +09:00
Hare 33a2b5d702 fix: unify workspace request routing 2026-09-12 23:49:13 +09:00
Hare 7f807004ad Update AGENTS.md 2026-09-12 22:47:35 +09:00
Hare 5564425488 fix: retry retained workdir removal 2026-09-12 13:30:25 +09:00
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 89856eb7c3 chore: replace legacy SQLite migrations with baselines 2026-09-04 12:48:45 +09:00
Hare 64c268582d fix: keep runtime SSH sockets below path limits 2026-09-04 12:48:25 +09:00
Hare bb6558e7bf fix: log Worker execution restore failures 2026-09-04 12:48:15 +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
280 changed files with 62787 additions and 37234 deletions
+7 -9
View File
@@ -1,21 +1,19 @@
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。 すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
## このシステムに置ける設計要旨 ## このシステムに置ける設計要旨
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。 - プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。 - 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
### LLM コンテキストの加工原則 ### LLM コンテキストの加工原則
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止** LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等) 一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。 これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。 過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
--- ---
Generated
+52 -654
View File
@@ -2,15 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "addr2line"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -177,12 +168,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.1" version = "1.9.1"
@@ -476,9 +461,6 @@ name = "bumpalo"
version = "3.20.2" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
dependencies = [
"allocator-api2",
]
[[package]] [[package]]
name = "bytecount" name = "bytecount"
@@ -662,15 +644,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -765,15 +738,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpp_demangle"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -792,148 +756,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cranelift-assembler-x64"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7"
dependencies = [
"cranelift-entity",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-bitset"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69"
dependencies = [
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
"cranelift-codegen-shared",
"cranelift-control",
"cranelift-entity",
"cranelift-isle",
"gimli",
"hashbrown 0.17.1",
"libm",
"log",
"pulley-interpreter",
"regalloc2",
"rustc-hash",
"serde",
"smallvec",
"target-lexicon",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen-meta"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
"cranelift-srcgen",
"heck",
"pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518"
[[package]]
name = "cranelift-control"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b"
dependencies = [
"cranelift-bitset",
"serde",
"serde_derive",
"wasmtime-internal-core",
]
[[package]]
name = "cranelift-frontend"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a"
dependencies = [
"cranelift-codegen",
"log",
"smallvec",
"target-lexicon",
]
[[package]]
name = "cranelift-isle"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087"
[[package]]
name = "cranelift-native"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc"
dependencies = [
"cranelift-codegen",
"libc",
"target-lexicon",
]
[[package]]
name = "cranelift-srcgen"
version = "0.132.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -1374,18 +1196,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -1788,18 +1598,6 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "gimli"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"fnv",
"hashbrown 0.16.1",
"indexmap",
"stable_deref_trait",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.3" version = "0.3.3"
@@ -1922,11 +1720,6 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "hashlink" name = "hashlink"
@@ -2614,15 +2407,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "manifest" name = "manifest"
version = "0.1.0" version = "0.1.0"
@@ -2713,15 +2497,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memfd"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227"
dependencies = [
"rustix 1.1.4",
]
[[package]] [[package]]
name = "memmap2" name = "memmap2"
version = "0.9.10" version = "0.9.10"
@@ -3003,18 +2778,6 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"crc32fast",
"hashbrown 0.17.1",
"indexmap",
"memchr",
]
[[package]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.7.1" version = "0.7.1"
@@ -3410,18 +3173,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]] [[package]]
name = "postscript" name = "postscript"
version = "0.14.1" version = "0.14.1"
@@ -3524,29 +3275,6 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "pulley-interpreter"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f"
dependencies = [
"cranelift-bitset",
"log",
"pulley-macros",
"wasmtime-internal-core",
]
[[package]]
name = "pulley-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -3824,20 +3552,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "regalloc2"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186"
dependencies = [
"allocator-api2",
"bumpalo",
"hashbrown 0.17.1",
"log",
"rustc-hash",
"smallvec",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.3"
@@ -3971,12 +3685,6 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -4216,10 +3924,6 @@ name = "semver"
version = "1.0.27" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde" name = "serde"
@@ -4393,6 +4097,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"session-store", "session-store",
"tempfile",
] ]
[[package]] [[package]]
@@ -4536,9 +4241,6 @@ name = "smallvec"
version = "1.15.1" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -4771,12 +4473,6 @@ dependencies = [
"xattr", "xattr",
] ]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "target-triple" name = "target-triple"
version = "1.0.0" version = "1.0.0"
@@ -5086,8 +4782,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"rustls",
"rustls-pki-types",
"tokio", "tokio",
"tokio-rustls",
"tungstenite 0.29.0", "tungstenite 0.29.0",
"webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -5257,6 +4957,16 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.23" version = "0.3.23"
@@ -5267,12 +4977,15 @@ dependencies = [
"nu-ansi-term", "nu-ansi-term",
"once_cell", "once_cell",
"regex-automata", "regex-automata",
"serde",
"serde_json",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing", "tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
"tracing-serde",
] ]
[[package]] [[package]]
@@ -5382,6 +5095,8 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"rand 0.9.4", "rand 0.9.4",
"rustls",
"rustls-pki-types",
"sha1", "sha1",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -5683,27 +5398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [ dependencies = [
"leb128fmt", "leb128fmt",
"wasmparser 0.244.0", "wasmparser",
]
[[package]]
name = "wasm-encoder"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6"
dependencies = [
"leb128fmt",
"wasmparser 0.248.0",
]
[[package]]
name = "wasm-encoder"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f"
dependencies = [
"leb128fmt",
"wasmparser 0.252.0",
] ]
[[package]] [[package]]
@@ -5714,8 +5409,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap", "indexmap",
"wasm-encoder 0.244.0", "wasm-encoder",
"wasmparser 0.244.0", "wasmparser",
] ]
[[package]] [[package]]
@@ -5743,286 +5438,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "wasmparser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.17.1",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "wasmparser"
version = "0.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c"
dependencies = [
"bitflags 2.11.0",
"indexmap",
"semver",
]
[[package]]
name = "wasmprinter"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183"
dependencies = [
"anyhow",
"termcolor",
"wasmparser 0.248.0",
]
[[package]]
name = "wasmtime"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527"
dependencies = [
"addr2line",
"async-trait",
"bitflags 2.11.0",
"bumpalo",
"cc",
"cfg-if",
"encoding_rs",
"libc",
"log",
"mach2",
"memfd",
"object",
"once_cell",
"postcard",
"pulley-interpreter",
"rustix 1.1.4",
"semver",
"serde",
"serde_derive",
"smallvec",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-component-macro",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
"wasmtime-internal-fiber",
"wasmtime-internal-jit-debug",
"wasmtime-internal-jit-icache-coherence",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
"wasmtime-internal-winch",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-environ"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71"
dependencies = [
"anyhow",
"cpp_demangle",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-entity",
"gimli",
"hashbrown 0.17.1",
"indexmap",
"log",
"object",
"postcard",
"rustc-demangle",
"semver",
"serde",
"serde_derive",
"sha2 0.10.9",
"smallvec",
"target-lexicon",
"wasm-encoder 0.248.0",
"wasmparser 0.248.0",
"wasmprinter",
"wasmtime-internal-component-util",
"wasmtime-internal-core",
]
[[package]]
name = "wasmtime-internal-component-macro"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96c17f35fae2ab574667aba0c58fd56349a6f788ac42541a2e543116d5cfb91"
dependencies = [
"anyhow",
"proc-macro2",
"quote",
"syn 2.0.117",
"wasmtime-internal-component-util",
"wasmtime-internal-wit-bindgen",
"wit-parser 0.248.0",
]
[[package]]
name = "wasmtime-internal-component-util"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2eeb9b53222859e6f5dc73d2ccfb33254d672469cac11b693a71912e2f3817"
[[package]]
name = "wasmtime-internal-core"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c"
dependencies = [
"hashbrown 0.17.1",
"libm",
"serde",
]
[[package]]
name = "wasmtime-internal-cranelift"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b"
dependencies = [
"cfg-if",
"cranelift-codegen",
"cranelift-control",
"cranelift-entity",
"cranelift-frontend",
"cranelift-native",
"gimli",
"itertools",
"log",
"object",
"pulley-interpreter",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-fiber"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a"
dependencies = [
"cc",
"cfg-if",
"libc",
"rustix 1.1.4",
"wasmtime-environ",
"wasmtime-internal-versioned-export-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-jit-debug"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551"
dependencies = [
"cc",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6"
dependencies = [
"cfg-if",
"libc",
"wasmtime-internal-core",
"windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-unwinder"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5"
dependencies = [
"cfg-if",
"cranelift-codegen",
"log",
"object",
"wasmtime-environ",
]
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "wasmtime-internal-winch"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "828b66175c54a0d00b4c1c1c76658d8aa73aeb9fa3553575c5eee56d40f2eb18"
dependencies = [
"cranelift-codegen",
"gimli",
"log",
"object",
"target-lexicon",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-cranelift",
"winch-codegen",
]
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae00896ad9bef1b3ca6401ae9a841daa6f357dd91541b6baf87082946d1bde1"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"heck",
"indexmap",
"wit-parser 0.248.0",
]
[[package]]
name = "wast"
version = "252.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder 0.252.0",
]
[[package]]
name = "wat"
version = "1.252.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb"
dependencies = [
"wast",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.94" version = "0.3.94"
@@ -6120,6 +5535,24 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
@@ -6229,25 +5662,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "45.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89c09acfdfa281b3340e1e94ef3cf6618d69eab975280f881e154c29f49419c1"
dependencies = [
"cranelift-assembler-x64",
"cranelift-codegen",
"gimli",
"regalloc2",
"smallvec",
"target-lexicon",
"thiserror 2.0.18",
"wasmparser 0.248.0",
"wasmtime-environ",
"wasmtime-internal-core",
"wasmtime-internal-cranelift",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -6511,7 +5925,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck",
"wit-parser 0.244.0", "wit-parser",
] ]
[[package]] [[package]]
@@ -6558,10 +5972,10 @@ dependencies = [
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"wasm-encoder 0.244.0", "wasm-encoder",
"wasm-metadata", "wasm-metadata",
"wasmparser 0.244.0", "wasmparser",
"wit-parser 0.244.0", "wit-parser",
] ]
[[package]] [[package]]
@@ -6579,26 +5993,7 @@ dependencies = [
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"unicode-xid", "unicode-xid",
"wasmparser 0.244.0", "wasmparser",
]
[[package]]
name = "wit-parser"
version = "0.248.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856"
dependencies = [
"anyhow",
"hashbrown 0.17.1",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser 0.248.0",
] ]
[[package]] [[package]]
@@ -6650,6 +6045,7 @@ dependencies = [
"serial_test", "serial_test",
"session-metrics", "session-metrics",
"session-store", "session-store",
"sha2 0.11.0",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
@@ -6660,11 +6056,8 @@ dependencies = [
"tracing", "tracing",
"tungstenite 0.28.0", "tungstenite 0.28.0",
"uuid", "uuid",
"wasmtime",
"wat",
"workdir", "workdir",
"workspace-api", "workspace-api",
"yoi-plugin-pdk",
] ]
[[package]] [[package]]
@@ -6677,6 +6070,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"decodal", "decodal",
"flow", "flow",
"fs4",
"futures", "futures",
"manifest", "manifest",
"protocol", "protocol",
@@ -6694,6 +6088,8 @@ dependencies = [
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"toml", "toml",
"tower", "tower",
"tracing",
"tracing-subscriber",
"url", "url",
"uuid", "uuid",
"workdir", "workdir",
@@ -6782,6 +6178,7 @@ dependencies = [
"tokio", "tokio",
"toml", "toml",
"tui", "tui",
"uuid",
"worker", "worker",
"workspace-api", "workspace-api",
] ]
@@ -6839,6 +6236,7 @@ dependencies = [
"toml", "toml",
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber",
"ts-rs", "ts-rs",
"url", "url",
"uuid", "uuid",
+1
View File
@@ -132,6 +132,7 @@ tokio-tungstenite = "0.29"
tower = "0.5" tower = "0.5"
toml = "1.1" toml = "1.1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
url = "2.5" url = "2.5"
uuid = "1.23" uuid = "1.23"
zeroize = "1" zeroize = "1"
+6 -3
View File
@@ -40,7 +40,7 @@ use tracing_subscriber::EnvFilter;
use agen::{ use agen::{
Engine, EngineRunExit, RunInterruptionReason, Engine, EngineRunExit, RunInterruptionReason,
interceptor::{Interceptor, PostToolAction, ToolResultInfo}, interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
llm_client::{ llm_client::{
LlmClient, LlmClient,
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport}, capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
#[async_trait] #[async_trait]
impl Interceptor for ToolResultPrinterPolicy { impl Interceptor for ToolResultPrinterPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
let name = self let name = self
.call_names .call_names
.lock() .lock()
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
println!(" Result ({}): ✅ {}", name, info.result.summary); println!(" Result ({}): ✅ {}", name, info.result.summary);
} }
PostToolAction::Continue Ok(PostToolAction::Continue)
} }
} }
+336 -106
View File
@@ -15,8 +15,12 @@ use crate::{
}, },
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind}, handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
interceptor::{ interceptor::{
DefaultInterceptor, Interceptor, PostToolAction, PreRequestAction, PreToolAction, AssistantTurnEndContext, DefaultInterceptor, Interceptor, InterceptorCallId,
PromptAction, ToolCallInfo, ToolResultInfo, TurnEndAction, InterceptorCounter, InterceptorCounters, InterceptorError, InterceptorErrorCategory,
InterceptorFailure, InterceptorInvocation, InterceptorPhase, InterceptorRunId,
InterceptorTurnId, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, RunExitContext,
ToolCallInfo, ToolResultInfo, TurnEndAction,
}, },
llm_client::{ llm_client::{
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream, ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream,
@@ -58,6 +62,9 @@ pub enum EngineError {
/// A durable-history observer rejected an item before it entered history. /// A durable-history observer rejected an item before it entered history.
#[error("History append failed: {0}")] #[error("History append failed: {0}")]
HistoryAppend(String), HistoryAppend(String),
/// A trusted host interceptor callback failed.
#[error(transparent)]
Interceptor(#[from] InterceptorFailure),
/// Tool terminalization lost its execution-attempt compare-and-set fence. /// Tool terminalization lost its execution-attempt compare-and-set fence.
#[error("Tool execution attempt fence failed: {0}")] #[error("Tool execution attempt fence failed: {0}")]
ToolAttemptFence(String), ToolAttemptFence(String),
@@ -181,7 +188,7 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
/// Result of [`Engine::run`] or [`Engine::resume`]. /// Result of [`Engine::run`] or [`Engine::resume`].
/// ///
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome. /// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
pub struct EngineRunOutput<C: LlmClient, A = ()> { pub struct EngineRunOutput<C: LlmClient, A: Send + Sync = ()> {
/// The Engine, now in Locked state. /// The Engine, now in Locked state.
pub engine: Engine<C, Locked, A>, pub engine: Engine<C, Locked, A>,
/// Outcome of the turn. /// Outcome of the turn.
@@ -305,7 +312,7 @@ enum StreamCompletion {
Interrupted { reason: String }, Interrupted { reason: String },
} }
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> { pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
/// LLM client /// LLM client
client: C, client: C,
/// Retry policy for opening an LLM response stream. /// Retry policy for opening an LLM response stream.
@@ -322,7 +329,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
/// Tool server handle /// Tool server handle
tool_server: ToolServerHandle, tool_server: ToolServerHandle,
/// Interceptor for control-flow decisions /// Interceptor for control-flow decisions
interceptor: Box<dyn Interceptor>, interceptor: Box<dyn Interceptor<A>>,
/// System prompt /// System prompt
system_prompt: Option<String>, system_prompt: Option<String>,
/// History length at lock time (only meaningful in Locked state) /// History length at lock time (only meaningful in Locked state)
@@ -341,6 +348,11 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
/// `max_turns` is enforced against this run-scoped count rather than the /// `max_turns` is enforced against this run-scoped count rather than the
/// cumulative `turn_count` above. /// cumulative `turn_count` above.
active_run_turn_count: Option<usize>, active_run_turn_count: Option<usize>,
/// Identity retained across pause/yield and resume.
active_run_id: Option<InterceptorRunId>,
next_run_id: u64,
interceptor_invocation_count: usize,
last_run_exit_observer_failure: Option<InterceptorFailure>,
/// LlmCall count (per-Engine running counter, monotonic). Unlike /// LlmCall count (per-Engine running counter, monotonic). Unlike
/// `turn_count` this never collapses retries. /// `turn_count` this never collapses retries.
llm_call_count: usize, llm_call_count: usize,
@@ -421,21 +433,57 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
_state: PhantomData<(S, A)>, _state: PhantomData<(S, A)>,
} }
impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> { impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
fn start_logical_run(&mut self) { fn start_logical_run(&mut self) {
self.active_run_turn_count = Some(0); self.active_run_turn_count = Some(0);
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
self.last_run_exit_observer_failure = None;
} }
fn ensure_logical_run(&mut self) { fn ensure_logical_run(&mut self) {
self.active_run_turn_count.get_or_insert(0); self.active_run_turn_count.get_or_insert(0);
if self.active_run_id.is_none() {
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
}
} }
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) { fn interceptor_invocation(
if !matches!( &mut self,
result, phase: InterceptorPhase,
Ok(EngineResult::Paused | EngineResult::Yielded) | Err(EngineError::PauseRequested) turn_id: Option<usize>,
) { call_id: Option<InterceptorCallId>,
tool_call: usize,
) -> InterceptorInvocation {
let invocation = self.interceptor_invocation_count;
self.interceptor_invocation_count = self.interceptor_invocation_count.saturating_add(1);
InterceptorInvocation {
run_id: self
.active_run_id
.expect("logical run identity must exist before interception"),
turn_id: turn_id.map(|value| InterceptorTurnId(value as u64)),
call_id,
phase,
counters: InterceptorCounters {
invocation: InterceptorCounter::from_usize(invocation),
engine_turn: InterceptorCounter::from_usize(self.turn_count),
run_turn: InterceptorCounter::from_usize(
self.active_run_turn_count.unwrap_or_default(),
),
llm_call: InterceptorCounter::from_usize(self.llm_call_count),
tool_batch: InterceptorCounter::from_usize(self.tool_execution_batch_count),
tool_call: InterceptorCounter::from_usize(tool_call),
},
}
}
fn finish_logical_run(&mut self, exit: &EngineRunExit) {
if !matches!(exit, EngineRunExit::Paused | EngineRunExit::Yielded) {
self.active_run_turn_count = None; self.active_run_turn_count = None;
self.active_run_id = None;
} }
} }
@@ -741,7 +789,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
/// The interceptor governs approval, skip, pause, and abort decisions /// The interceptor governs approval, skip, pause, and abort decisions
/// at key points in the execution loop. If not set, the default /// at key points in the execution loop. If not set, the default
/// interceptor is used (all Continue / Finish). /// interceptor is used (all Continue / Finish).
pub fn set_interceptor(&mut self, interceptor: impl Interceptor + 'static) { pub fn set_interceptor(&mut self, interceptor: impl Interceptor<A> + 'static) {
self.interceptor = Box::new(interceptor); self.interceptor = Box::new(interceptor);
} }
@@ -842,6 +890,10 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
/// ///
/// `Some` is retained only while Pause or Yield permits a later /// `Some` is retained only while Pause or Yield permits a later
/// [`resume`](Self::resume). Terminal outcomes return this to `None`. /// [`resume`](Self::resume). Terminal outcomes return this to `None`.
pub fn last_run_exit_observer_failure(&self) -> Option<&InterceptorFailure> {
self.last_run_exit_observer_failure.as_ref()
}
pub fn active_run_turn_count(&self) -> Option<usize> { pub fn active_run_turn_count(&self) -> Option<usize> {
self.active_run_turn_count self.active_run_turn_count
} }
@@ -853,6 +905,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
/// [`resume`](Self::resume) starts a fresh budget. /// [`resume`](Self::resume) starts a fresh budget.
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) { pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
self.active_run_turn_count = turn_count; self.active_run_turn_count = turn_count;
if turn_count.is_none() {
self.active_run_id = None;
} else if self.active_run_id.is_none() {
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
}
} }
/// Get the current LlmCall count (per-Engine running counter, never /// Get the current LlmCall count (per-Engine running counter, never
@@ -1078,24 +1137,28 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
request request
} }
/// Hooks: on_prompt_submit async fn finalize_run_exit(
///
async fn finalize_interruption<T>(
&mut self, &mut self,
result: Result<T, EngineError>, history: &History<A>,
) -> Result<T, EngineError> { result: Result<EngineResult, EngineError>,
match result { ) -> EngineRunExit {
Ok(value) => Ok(value), let exit = EngineRunExit::from(result);
Err(err) => { let invocation = self.interceptor_invocation(InterceptorPhase::RunExit, None, None, 0);
let reason = match &err { self.last_run_exit_observer_failure = None;
EngineError::Aborted(reason) => reason.clone(), if let Err(error) = self
EngineError::Cancelled => "Cancelled".to_string(), .interceptor
_ => err.to_string(), .on_run_exit(RunExitContext {
}; invocation,
self.interceptor.on_abort(&reason).await; exit: &exit,
Err(err) history: history.entries(),
} })
.await
{
self.last_run_exit_observer_failure =
Some(InterceptorFailure::new(InterceptorPhase::RunExit, error));
} }
self.finish_logical_run(&exit);
exit
} }
/// Check for pending tool calls (for resuming from Pause) /// Check for pending tool calls (for resuming from Pause)
@@ -1166,21 +1229,60 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result) // Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new(); let mut approved_calls = Vec::new();
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() { for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
let expected_tool_use_id = tool_call.id.clone();
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index); let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) { if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
let invocation = self.interceptor_invocation(
InterceptorPhase::PreToolCall,
Some(self.turn_count.saturating_sub(1)),
Some(InterceptorCallId::Tool(expected_tool_use_id.clone())),
call_index,
);
let mut info = ToolCallInfo { let mut info = ToolCallInfo {
invocation,
history: history.entries(),
call: tool_call.clone(), call: tool_call.clone(),
meta, meta,
tool, tool,
context, context,
}; };
match self.interceptor.pre_tool_call(&mut info).await { let pre_tool_action =
self.interceptor
.pre_tool_call(&mut info)
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
error,
))
})?;
if info.call.id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"pre-tool interceptor changed immutable tool call identity",
),
)
.into());
}
match pre_tool_action {
PreToolAction::Continue => {} PreToolAction::Continue => {}
PreToolAction::Skip => { PreToolAction::Skip => {
continue; continue;
} }
PreToolAction::SyntheticResult(result) => { PreToolAction::SyntheticResult(result) => {
if result.tool_use_id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
let tool_call = info.call; let tool_call = info.call;
let mut context = info.context; let mut context = info.context;
context.call_id = tool_call.id.clone(); context.call_id = tool_call.id.clone();
@@ -1287,20 +1389,31 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let mut terminal_call_ids = HashSet::new(); let mut terminal_call_ids = HashSet::new();
let mut pause_requested = false; let mut pause_requested = false;
let mut pause_deadline = None; let mut pause_deadline = None;
let mut batch_error = None;
let mut locally_enqueued_cancel = false;
for result in synthetic_results { for result in synthetic_results {
self.finalize_and_commit_tool_result( if let Err(error) = self
history, .finalize_and_commit_tool_result(
annotate, history,
result, annotate,
None, result,
&call_info_map, None,
&mut attempt_fence, &call_info_map,
&mut terminal_call_ids, &mut attempt_fence,
) &mut terminal_call_ids,
.await?; )
.await
&& batch_error.is_none()
{
batch_error = Some(error);
}
} }
let mut futures = futures; let mut futures = futures;
if batch_error.is_some() && !futures.is_empty() {
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
while !futures.is_empty() { while !futures.is_empty() {
tokio::select! { tokio::select! {
// If cancellation and a completed result are both ready, drain // If cancellation and a completed result are both ready, drain
@@ -1310,7 +1423,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
result = futures.next() => { result = futures.next() => {
let (attempt_id, result) = let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a result"); result.expect("non-empty FuturesUnordered returns a result");
self.finalize_and_commit_tool_result( if let Err(error) = self.finalize_and_commit_tool_result(
history, history,
annotate, annotate,
result, result,
@@ -1318,7 +1431,15 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map, &call_info_map,
&mut attempt_fence, &mut attempt_fence,
&mut terminal_call_ids, &mut terminal_call_ids,
).await?; ).await {
if batch_error.is_none() {
batch_error = Some(error);
}
if !futures.is_empty() {
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
}
} }
pause = self.pause_rx.recv(), if !pause_requested => { pause = self.pause_rx.recv(), if !pause_requested => {
if pause.is_some() { if pause.is_some() {
@@ -1335,6 +1456,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => { _ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
pause_deadline = None; pause_deadline = None;
let _ = self.cancel_tx.try_send(()); let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
} }
cancel = self.cancel_rx.recv() => { cancel = self.cancel_rx.recv() => {
if cancel.is_some() { if cancel.is_some() {
@@ -1380,7 +1502,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
result = futures.next() => { result = futures.next() => {
let (attempt_id, result) = let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a result"); result.expect("non-empty FuturesUnordered returns a result");
self.finalize_and_commit_tool_result( if let Err(error) = self.finalize_and_commit_tool_result(
history, history,
annotate, annotate,
result, result,
@@ -1388,7 +1510,11 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map, &call_info_map,
&mut attempt_fence, &mut attempt_fence,
&mut terminal_call_ids, &mut terminal_call_ids,
).await?; ).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
} }
_ = tokio::time::sleep_until(deadline) => break, _ = tokio::time::sleep_until(deadline) => break,
} }
@@ -1402,7 +1528,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
if let Some(handle) = execution_handles.get(call_id) { if let Some(handle) = execution_handles.get(call_id) {
handle.force_close(); handle.force_close();
} }
self.finalize_and_commit_tool_result( if let Err(error) = self.finalize_and_commit_tool_result(
history, history,
annotate, annotate,
ToolResult::outcome_unknown(call_id), ToolResult::outcome_unknown(call_id),
@@ -1410,11 +1536,18 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map, &call_info_map,
&mut attempt_fence, &mut attempt_fence,
&mut terminal_call_ids, &mut terminal_call_ids,
).await?; ).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
} }
} }
self.timeline.abort_current_block(); self.timeline.abort_current_block();
if let Some(error) = batch_error.take() {
return Err(error);
}
if pause_requested { if pause_requested {
return Ok(ToolExecutionResult::Paused); return Ok(ToolExecutionResult::Paused);
} }
@@ -1423,6 +1556,16 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
} }
} }
// A result-biased ready sibling can empty the batch before the local
// cancel signal is selected. Never let that current-batch signal leak
// into the next run or resume call.
if locally_enqueued_cancel {
let _ = self.cancel_rx.try_recv();
}
if let Some(error) = batch_error {
self.timeline.abort_current_block();
return Err(error);
}
Ok(if pause_requested { Ok(if pause_requested {
ToolExecutionResult::Paused ToolExecutionResult::Paused
} else { } else {
@@ -1466,31 +1609,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
} }
let call_info = call_info_map.get(&tool_result.tool_use_id); let call_info = call_info_map.get(&tool_result.tool_use_id);
let mut abort_reason = None;
if let Some((tool_call, meta, tool, context)) = call_info {
let mut info = ToolResultInfo {
call: tool_call.clone(),
result: tool_result,
meta: meta.clone(),
tool: tool.clone(),
context: context.clone(),
};
match self.interceptor.post_tool_call(&mut info).await {
PostToolAction::Continue => {}
PostToolAction::Abort(reason) => {
abort_reason = Some(reason);
}
}
tool_result = info.result;
}
if tool_result.is_error && tool_result.disposition.is_success() { if tool_result.is_error && tool_result.disposition.is_success() {
tool_result.disposition = ToolResultDisposition::Error; tool_result.disposition = ToolResultDisposition::Error;
} }
tool_result.is_error = !tool_result.disposition.is_success(); tool_result.is_error = !tool_result.disposition.is_success();
// Cap content only after post_tool_call so interceptors still observe // Bound the terminal payload before committing it so the post-tool
// the full payload and any content they inject is bounded too. // interceptor observes exactly the model-visible durable result.
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = ( if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
self.tool_output_limits.as_ref(), self.tool_output_limits.as_ref(),
call_info, call_info,
@@ -1543,9 +1668,38 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
"Tool execution terminalized" "Tool execution terminalized"
); );
self.emit_tool_result(&tool_result); self.emit_tool_result(&tool_result);
if let Some(reason) = abort_reason {
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) Ok(true)
} }
@@ -1608,11 +1762,25 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
// These are committed *before* the per-request clone so they // These are committed *before* the per-request clone so they
// participate in the LLM request below and get persisted by // participate in the LLM request below and get persisted by
// the caller that owns durable history. // the caller that owns durable history.
let pending_invocation = self.interceptor_invocation(
InterceptorPhase::PendingHistoryAppends,
Some(current_turn),
None,
0,
);
let pending = self let pending = self
.interceptor .interceptor
.pending_history_appends() .pending_history_appends(PendingHistoryAppendsContext {
invocation: pending_invocation,
history: history.entries(),
})
.await .await
.map_err(EngineError::HistoryAppend)?; .map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PendingHistoryAppends,
error,
))
})?;
if !pending.is_empty() { if !pending.is_empty() {
self.append_history_items(history, pending, annotate)?; self.append_history_items(history, pending, annotate)?;
} }
@@ -1679,7 +1847,27 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
} }
// Interceptor: pre_llm_request // Interceptor: pre_llm_request
match self.interceptor.pre_llm_request(&mut request_context).await { let request_invocation = self.interceptor_invocation(
InterceptorPhase::PreLlmRequest,
Some(current_turn),
Some(InterceptorCallId::Llm(self.llm_call_count as u64)),
0,
);
let pre_request_action = self
.interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: request_invocation,
items: &mut request_context,
history: history.entries(),
})
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PreLlmRequest,
error,
))
})?;
match pre_request_action {
PreRequestAction::Cancel(reason) => { PreRequestAction::Cancel(reason) => {
info!(reason = %reason, "Aborted by interceptor"); info!(reason = %reason, "Aborted by interceptor");
for cb in &self.turn_end_cbs { for cb in &self.turn_end_cbs {
@@ -1791,21 +1979,45 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let tool_calls = self.tool_call_collector.take_collected(); let tool_calls = self.tool_call_collector.take_collected();
let assistant_items = let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls); self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
let assistant_start = history.len();
self.append_history_items(history, assistant_items, annotate)?; self.append_history_items(history, assistant_items, annotate)?;
if tool_calls.is_empty() { let assistant_invocation = self.interceptor_invocation(
let turn_end_context = history.items_cloned(); InterceptorPhase::AssistantTurnEnd,
match self.interceptor.on_turn_end(&turn_end_context).await { Some(current_turn),
TurnEndAction::Finish => { Some(InterceptorCallId::Llm(
return Ok(EngineResult::Finished); self.llm_call_count.saturating_sub(1) as u64,
} )),
TurnEndAction::ContinueWithMessages(additional) => { 0,
self.append_history_items(history, additional, annotate)?; );
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; 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). /// Create a new annotated Engine (in Mutable state).
pub fn new_annotated(client: C) -> Self { pub fn new_annotated(client: C) -> Self {
let text_block_collector = TextBlockCollector::new(); let text_block_collector = TextBlockCollector::new();
@@ -2126,6 +2338,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len: 0, locked_prefix_len: 0,
turn_count: 0, turn_count: 0,
active_run_turn_count: None, active_run_turn_count: None,
active_run_id: None,
next_run_id: 1,
interceptor_invocation_count: 0,
last_run_exit_observer_failure: None,
llm_call_count: 0, llm_call_count: 0,
tool_execution_batch_count: 0, tool_execution_batch_count: 0,
max_turns: None, max_turns: None,
@@ -2401,6 +2617,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len, locked_prefix_len,
turn_count: self.turn_count, turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count, active_run_turn_count: self.active_run_turn_count,
active_run_id: self.active_run_id,
next_run_id: self.next_run_id,
interceptor_invocation_count: self.interceptor_invocation_count,
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
llm_call_count: self.llm_call_count, llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count, tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns, max_turns: self.max_turns,
@@ -2477,7 +2697,7 @@ impl<C: LlmClient> Engine<C, Mutable, ()> {
} }
} }
impl<C: LlmClient, A> Engine<C, Locked, A> { impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
/// Execute a turn /// Execute a turn
/// ///
/// Adds a new user message to history and sends a request to the LLM. /// Adds a new user message to history and sends a request to the LLM.
@@ -2488,9 +2708,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
user_input: impl Into<String>, user_input: impl Into<String>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>, annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit { ) -> EngineRunExit {
self.run_result_with_annotation(history, user_input.into(), annotate) let result = self
.await .run_result_with_annotation(history, user_input.into(), annotate)
.into() .await;
self.finalize_run_exit(history, result).await
} }
async fn run_result_with_annotation( async fn run_result_with_annotation(
@@ -2501,13 +2722,26 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
) -> Result<EngineResult, EngineError> { ) -> Result<EngineResult, EngineError> {
// Supplying new user input abandons any paused/yielded logical run. // Supplying new user input abandons any paused/yielded logical run.
self.active_run_turn_count = None; self.active_run_turn_count = None;
self.active_run_id = None;
self.start_logical_run();
let mut user_item = Item::user_message(user_input); let mut user_item = Item::user_message(user_input);
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await { let invocation = self.interceptor_invocation(InterceptorPhase::PromptSubmit, None, None, 0);
PromptAction::Cancel(reason) => { let prompt_action = self
return self .interceptor
.finalize_interruption(Err(EngineError::Aborted(reason))) .on_prompt_submit(PromptSubmitContext {
.await; invocation,
} item: &mut user_item,
history: history.entries(),
})
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PromptSubmit,
error,
))
})?;
let extras = match prompt_action {
PromptAction::Cancel(reason) => return Err(EngineError::Aborted(reason)),
PromptAction::Continue => Vec::new(), PromptAction::Continue => Vec::new(),
PromptAction::ContinueWith(items) => items, PromptAction::ContinueWith(items) => items,
}; };
@@ -2515,14 +2749,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
if !extras.is_empty() { if !extras.is_empty() {
self.append_history_items(history, extras, annotate)?; self.append_history_items(history, extras, annotate)?;
} }
self.start_logical_run(); match self.run_turn_loop(history, annotate).await {
let result = match self.run_turn_loop(history, annotate).await {
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused), Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other, other => other,
}; }
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Resume execution (from Paused state). /// Resume execution (from Paused state).
@@ -2531,9 +2761,8 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
history: &mut History<A>, history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>, annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit { ) -> EngineRunExit {
self.resume_result_with_annotation(history, annotate) let result = self.resume_result_with_annotation(history, annotate).await;
.await self.finalize_run_exit(history, result).await
.into()
} }
async fn resume_result_with_annotation( async fn resume_result_with_annotation(
@@ -2542,13 +2771,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
annotate: &mut impl FnMut(&Item) -> Result<A, String>, annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineResult, EngineError> { ) -> Result<EngineResult, EngineError> {
self.ensure_logical_run(); self.ensure_logical_run();
let result = match self.run_turn_loop(history, annotate).await { match self.run_turn_loop(history, annotate).await {
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused), Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other, other => other,
}; }
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
} }
/// Get the prefix length at lock time /// Get the prefix length at lock time
@@ -2574,6 +2800,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
locked_prefix_len: 0, locked_prefix_len: 0,
turn_count: self.turn_count, turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count, active_run_turn_count: self.active_run_turn_count,
active_run_id: self.active_run_id,
next_run_id: self.next_run_id,
interceptor_invocation_count: self.interceptor_invocation_count,
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
llm_call_count: self.llm_call_count, llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count, tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns, max_turns: self.max_turns,
+250 -28
View File
@@ -9,8 +9,202 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::Item; use crate::Item;
use crate::engine::EngineRunExit;
use crate::history::HistoryEntry;
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult}; use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
// =============================================================================
// Typed lifecycle metadata and failures
// =============================================================================
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
/// Stable category for the source of an interceptor failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterceptorErrorCategory {
Policy,
Dependency,
ContractViolation,
Internal,
}
impl std::fmt::Display for InterceptorErrorCategory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Policy => "policy",
Self::Dependency => "dependency",
Self::ContractViolation => "contract_violation",
Self::Internal => "internal",
})
}
}
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{category}: {diagnostic}")]
pub struct InterceptorError {
category: InterceptorErrorCategory,
diagnostic: String,
}
impl InterceptorError {
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
let mut diagnostic = diagnostic.into();
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
while !diagnostic.is_char_boundary(end) {
end -= 1;
}
diagnostic.truncate(end);
}
Self {
category,
diagnostic,
}
}
pub fn category(&self) -> InterceptorErrorCategory {
self.category
}
pub fn diagnostic(&self) -> &str {
&self.diagnostic
}
}
/// The lifecycle phase at which an interceptor callback executes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InterceptorPhase {
#[default]
PromptSubmit,
PendingHistoryAppends,
PreLlmRequest,
PreToolCall,
PostToolCall,
AssistantTurnEnd,
RunExit,
}
impl std::fmt::Display for InterceptorPhase {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::PromptSubmit => "prompt_submit",
Self::PendingHistoryAppends => "pending_history_appends",
Self::PreLlmRequest => "pre_llm_request",
Self::PreToolCall => "pre_tool_call",
Self::PostToolCall => "post_tool_call",
Self::AssistantTurnEnd => "assistant_turn_end",
Self::RunExit => "run_exit",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct InterceptorRunId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct InterceptorTurnId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InterceptorCallId {
Llm(u64),
Tool(String),
}
/// Saturating public counter used by interceptor contexts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct InterceptorCounter(u32);
impl InterceptorCounter {
pub fn from_usize(value: usize) -> Self {
Self(u32::try_from(value).unwrap_or(u32::MAX))
}
pub fn get(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct InterceptorCounters {
pub invocation: InterceptorCounter,
pub engine_turn: InterceptorCounter,
pub run_turn: InterceptorCounter,
pub llm_call: InterceptorCounter,
pub tool_batch: InterceptorCounter,
pub tool_call: InterceptorCounter,
}
/// Identity, phase, and bounded counters common to every lifecycle callback.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InterceptorInvocation {
pub run_id: InterceptorRunId,
pub turn_id: Option<InterceptorTurnId>,
pub call_id: Option<InterceptorCallId>,
pub phase: InterceptorPhase,
pub counters: InterceptorCounters,
}
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{phase} interceptor failed: {error}")]
pub struct InterceptorFailure {
phase: InterceptorPhase,
#[source]
error: InterceptorError,
}
impl InterceptorFailure {
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
Self { phase, error }
}
pub fn phase(&self) -> InterceptorPhase {
self.phase
}
pub fn error(&self) -> &InterceptorError {
&self.error
}
}
pub type InterceptorResult<T> = Result<T, InterceptorError>;
// =============================================================================
// Lifecycle Contexts
// =============================================================================
pub struct PromptSubmitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub item: &'a mut Item,
pub history: &'a [HistoryEntry<A>],
}
pub struct PendingHistoryAppendsContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
}
pub struct PreLlmRequestContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub items: &'a mut Vec<Item>,
pub history: &'a [HistoryEntry<A>],
}
pub struct AssistantTurnEndContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub assistant_entries: &'a [HistoryEntry<A>],
pub history: &'a [HistoryEntry<A>],
pub tool_calls: &'a [ToolCall],
}
pub struct RunExitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub exit: &'a EngineRunExit,
pub history: &'a [HistoryEntry<A>],
}
// ============================================================================= // =============================================================================
// Action Enums // Action Enums
// ============================================================================= // =============================================================================
@@ -86,9 +280,9 @@ pub enum PostToolAction {
/// Action at the end of a turn (when LLM produces no tool calls). /// Action at the end of a turn (when LLM produces no tool calls).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum TurnEndAction { pub enum TurnEndAction {
/// Turn is finished, return to caller. /// Accept the Engine's natural next phase: execute tools, or finish when none exist.
Finish, Finish,
/// Continue with additional messages injected into history. /// Commit additional messages, then continue through the natural next phase.
ContinueWithMessages(Vec<Item>), ContinueWithMessages(Vec<Item>),
/// Pause execution (can be resumed later). /// Pause execution (can be resumed later).
Pause, Pause,
@@ -99,8 +293,9 @@ pub enum TurnEndAction {
// ============================================================================= // =============================================================================
/// Context for pre-tool-call decisions. /// Context for pre-tool-call decisions.
pub struct ToolCallInfo { pub struct ToolCallInfo<'a, A = ()> {
/// Tool call information (modifiable). pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall, pub call: ToolCall,
/// Tool meta information. /// Tool meta information.
pub meta: ToolMeta, pub meta: ToolMeta,
@@ -111,10 +306,11 @@ pub struct ToolCallInfo {
} }
/// Context for post-tool-call decisions. /// Context for post-tool-call decisions.
pub struct ToolResultInfo { pub struct ToolResultInfo<'a, A = ()> {
/// Original tool call. pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall, pub call: ToolCall,
/// Tool execution result (modifiable). /// Committed terminal tool execution result.
pub result: ToolResult, pub result: ToolResult,
/// Tool meta information. /// Tool meta information.
pub meta: ToolMeta, pub meta: ToolMeta,
@@ -130,14 +326,22 @@ pub struct ToolResultInfo {
/// Intercepts the Engine execution loop at key decision points. /// Intercepts the Engine execution loop at key decision points.
/// ///
/// All methods have default implementations that let the Engine /// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
/// proceed without intervention. Callers provide richer implementations for /// keeping implementation failure separate from the method's control-flow
/// approval flows, permission checks, etc. /// action. The Engine reports a failure as a typed run interruption annotated
/// with the exact [`InterceptorPhase`] that failed.
///
/// All methods have default implementations that let the Engine proceed
/// without intervention. Callers provide richer implementations for approval
/// flows, permission checks, and other trusted host adaptation.
#[async_trait] #[async_trait]
pub trait Interceptor: Send + Sync { pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Called after receiving user input, before adding to history. /// Called after receiving user input, before adding it to Engine history.
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction { async fn on_prompt_submit(
PromptAction::Continue &self,
_context: PromptSubmitContext<'_, A>,
) -> InterceptorResult<PromptAction> {
Ok(PromptAction::Continue)
} }
/// Items that should be **committed to `engine.history`** just /// Items that should be **committed to `engine.history`** just
@@ -158,7 +362,10 @@ pub trait Interceptor: Send + Sync {
/// reproducible per-request transformations (pruning, content /// reproducible per-request transformations (pruning, content
/// trimming, cache anchors) that depend only on the existing /// trimming, cache anchors) that depend only on the existing
/// history. /// history.
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> { async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, A>,
) -> InterceptorResult<Vec<Item>> {
Ok(Vec::new()) Ok(Vec::new())
} }
@@ -170,27 +377,42 @@ pub trait Interceptor: Send + Sync {
/// If an interceptor derives a human/model-visible nudge from the current /// If an interceptor derives a human/model-visible nudge from the current
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine /// request context, return [`PreRequestAction::ContinueWith`] so the Engine
/// commits it to history before the request is sent. /// commits it to history before the request is sent.
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction { async fn pre_llm_request(
PreRequestAction::Continue &self,
_context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
Ok(PreRequestAction::Continue)
} }
/// Called before each tool is executed. /// Called before each tool is executed.
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
PreToolAction::Continue &self,
_info: &mut ToolCallInfo<'_, A>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::Continue)
} }
/// Called after each tool completes. /// Called after each tool reaches one terminal result and that result is committed.
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
PostToolAction::Continue &self,
_info: &ToolResultInfo<'_, A>,
) -> InterceptorResult<PostToolAction> {
Ok(PostToolAction::Continue)
} }
/// Called when a turn ends with no tool calls. /// Called after every terminal assistant response is committed and before
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction { /// the Engine decides whether to execute tools, continue, or finish.
TurnEndAction::Finish async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, A>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Finish)
} }
/// Called when execution is interrupted (abort or cancel). /// Called once for the terminal outcome of each public run or resume call.
async fn on_abort(&self, _reason: &str) {} async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
Ok(())
}
} }
/// Default interceptor: no intervention. Engine proceeds through the loop /// Default interceptor: no intervention. Engine proceeds through the loop
@@ -198,4 +420,4 @@ pub trait Interceptor: Send + Sync {
pub(crate) struct DefaultInterceptor; pub(crate) struct DefaultInterceptor;
#[async_trait] #[async_trait]
impl Interceptor for DefaultInterceptor {} impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
+7 -1
View File
@@ -26,7 +26,13 @@ pub use engine::{
}; };
pub use handler::ToolUseBlockStart; pub use handler::ToolUseBlockStart;
pub use history::{History, HistoryEntry}; pub use history::{History, HistoryEntry};
pub use interceptor::Interceptor; pub use interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
};
pub use message::{ContentPart, Item, Message, Role}; pub use message::{ContentPart, Item, Message, Role};
pub use tool::{ pub use tool::{
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy, ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
+126
View File
@@ -1,8 +1,15 @@
mod common; mod common;
use agen::interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role}; use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
use std::sync::{Arc, Mutex};
fn completed_text_events(text: &str) -> Vec<Event> { fn completed_text_events(text: &str) -> Vec<Event> {
vec![ vec![
@@ -47,6 +54,125 @@ async fn run_preserves_item_annotations_without_projecting_them() {
assert_eq!(history.items_cloned().len(), 2); assert_eq!(history.items_cloned().len(), 2);
} }
#[derive(Clone)]
struct AnnotationObservingInterceptor {
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
}
impl AnnotationObservingInterceptor {
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
self.observed.lock().unwrap().push((
invocation.clone(),
history
.iter()
.map(|entry| entry.annotation.clone())
.collect(),
));
}
}
#[async_trait]
impl Interceptor<String> for AnnotationObservingInterceptor {
async fn on_prompt_submit(
&self,
context: PromptSubmitContext<'_, String>,
) -> InterceptorResult<PromptAction> {
self.record(&context.invocation, context.history);
Ok(PromptAction::Continue)
}
async fn pending_history_appends(
&self,
context: PendingHistoryAppendsContext<'_, String>,
) -> InterceptorResult<Vec<Item>> {
self.record(&context.invocation, context.history);
Ok(Vec::new())
}
async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, String>,
) -> InterceptorResult<PreRequestAction> {
self.record(&context.invocation, context.history);
Ok(PreRequestAction::Continue)
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, String>,
) -> InterceptorResult<TurnEndAction> {
assert_eq!(context.assistant_entries.len(), 1);
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
self.record(&context.invocation, context.history);
Ok(TurnEndAction::Finish)
}
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
self.record(&context.invocation, context.history);
Ok(())
}
}
#[tokio::test]
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let observed = Arc::new(Mutex::new(Vec::new()));
engine.set_interceptor(AnnotationObservingInterceptor {
observed: observed.clone(),
});
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = if item.is_assistant_message() {
"assistant"
} else {
"user"
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await;
assert!(matches!(output.result, agen::EngineRunExit::Finished));
let observed = observed.lock().unwrap();
let phases: Vec<_> = observed
.iter()
.map(|(invocation, _)| invocation.phase)
.collect();
assert_eq!(
phases,
[
InterceptorPhase::PromptSubmit,
InterceptorPhase::PendingHistoryAppends,
InterceptorPhase::PreLlmRequest,
InterceptorPhase::AssistantTurnEnd,
InterceptorPhase::RunExit,
]
);
assert!(
observed
.iter()
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
);
assert_eq!(
observed
.iter()
.map(|(invocation, _)| invocation.counters.invocation.get())
.collect::<Vec<_>>(),
[0, 1, 2, 3, 4]
);
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[1].1, ["1:user"]);
assert_eq!(observed[2].1, ["1:user"]);
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
}
#[test] #[test]
fn append_failure_does_not_make_item_live() { fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
+519 -10
View File
@@ -10,9 +10,16 @@ use std::sync::{Arc, Mutex};
use agen::Item; use agen::Item;
use agen::interceptor::{ use agen::interceptor::{
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction, AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
InterceptorPhase as InterceptorPoint, InterceptorResult, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES,
PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext, PreRequestAction,
PreToolAction, PromptAction, PromptSubmitContext, RunExitContext, ToolCallInfo, ToolResultInfo,
TurnEndAction,
};
use agen::llm_client::{
ClientError, LlmClient, Request, ResponseStream,
event::{Event, ResponseStatus, StatusEvent},
}; };
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason}; use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
use async_trait::async_trait; use async_trait::async_trait;
@@ -613,12 +620,15 @@ struct YieldOnce {
#[async_trait] #[async_trait]
impl Interceptor for YieldOnce { impl Interceptor for YieldOnce {
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction { async fn pre_llm_request(
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { &self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreRequestAction::Yield PreRequestAction::Yield
} else { } else {
PreRequestAction::Continue PreRequestAction::Continue
} })
} }
} }
@@ -628,12 +638,15 @@ struct PauseToolOnce {
#[async_trait] #[async_trait]
impl Interceptor for PauseToolOnce { impl Interceptor for PauseToolOnce {
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { &self,
_info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreToolAction::Pause PreToolAction::Pause
} else { } else {
PreToolAction::Continue PreToolAction::Continue
} })
} }
} }
@@ -643,13 +656,509 @@ struct ContinueTurnOnce {
#[async_trait] #[async_trait]
impl Interceptor for ContinueTurnOnce { impl Interceptor for ContinueTurnOnce {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction { async fn on_assistant_turn_end(
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { &self,
_context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")]) TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
} else { } else {
TurnEndAction::Finish TurnEndAction::Finish
})
}
}
#[derive(Debug, Clone)]
struct FailingLifecycleInterceptor {
failure: InterceptorPoint,
calls: Arc<Mutex<Vec<InterceptorPoint>>>,
}
impl FailingLifecycleInterceptor {
fn new(failure: InterceptorPoint) -> Self {
Self {
failure,
calls: Arc::new(Mutex::new(Vec::new())),
} }
} }
fn record<T>(&self, point: InterceptorPoint, action: T) -> InterceptorResult<T> {
self.calls.lock().unwrap().push(point);
if self.failure == point {
Err(InterceptorError::new(
InterceptorErrorCategory::Policy,
format!("{point} rejected"),
))
} else {
Ok(action)
}
}
fn calls(&self) -> Vec<InterceptorPoint> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl Interceptor for FailingLifecycleInterceptor {
async fn on_prompt_submit(
&self,
_context: PromptSubmitContext<'_, ()>,
) -> InterceptorResult<PromptAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PromptSubmit, PromptAction::Continue)
}
async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, ()>,
) -> InterceptorResult<Vec<Item>> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PendingHistoryAppends, Vec::new())
}
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PreLlmRequest, PreRequestAction::Continue)
}
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PreToolCall, PreToolAction::Continue)
}
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PostToolCall, PostToolAction::Continue)
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
tokio::task::yield_now().await;
assert!(context.history.ends_with(context.assistant_entries));
if !context.tool_calls.is_empty() {
assert_eq!(
context
.assistant_entries
.iter()
.filter(|entry| matches!(&entry.item, Item::ToolCall { .. }))
.count(),
context.tool_calls.len()
);
}
self.record(InterceptorPoint::AssistantTurnEnd, TurnEndAction::Finish)
}
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::RunExit, ())
}
}
fn expected_interceptor_calls(failure: InterceptorPoint) -> Vec<InterceptorPoint> {
use InterceptorPoint as Point;
let mut calls = match failure {
Point::PromptSubmit => vec![Point::PromptSubmit],
Point::PendingHistoryAppends => {
vec![Point::PromptSubmit, Point::PendingHistoryAppends]
}
Point::PreLlmRequest => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
],
Point::PreToolCall => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
Point::PreToolCall,
],
Point::PostToolCall => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
Point::PreToolCall,
Point::PostToolCall,
],
Point::AssistantTurnEnd => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
],
Point::RunExit => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
],
};
calls.push(Point::RunExit);
calls
}
#[tokio::test]
async fn interceptor_failures_are_typed_and_terminal_observer_preserves_original_exit() {
use InterceptorPoint as Point;
for failure_point in [
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::PreToolCall,
Point::PostToolCall,
Point::AssistantTurnEnd,
Point::RunExit,
] {
let interceptor = FailingLifecycleInterceptor::new(failure_point);
let needs_tool = matches!(failure_point, Point::PreToolCall | Point::PostToolCall);
let events = if needs_tool {
vec![
Event::tool_use_start(0, "call-1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
} else {
completed_text_events()
};
let mut engine = Engine::new(MockLlmClient::new(events));
engine.register_tool(CountingTool::new("count_tool").definition());
engine.set_interceptor(interceptor.clone());
let mut history = History::new();
let mut engine = engine.lock(&history);
let exit = engine.run(&mut history, "test").await;
let failure = if failure_point == Point::RunExit {
assert!(matches!(exit, EngineRunExit::Finished));
engine
.last_run_exit_observer_failure()
.expect("terminal observer diagnostic should be retained")
} else {
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(failure),
)) = &exit
else {
panic!("expected typed interceptor interruption at {failure_point}, got {exit:?}");
};
failure
};
assert_eq!(failure.phase(), failure_point);
assert_eq!(
failure.error().diagnostic(),
format!("{failure_point} rejected")
);
assert_eq!(
interceptor.calls(),
expected_interceptor_calls(failure_point)
);
if failure_point == Point::PostToolCall {
assert!(
history
.items()
.any(|item| matches!(item, Item::ToolResult { .. })),
"post-tool failure must not precede terminal output commit"
);
}
}
}
#[test]
fn interceptor_error_keeps_typed_category_and_bounded_utf8_diagnostic() {
let error = InterceptorError::new(
InterceptorErrorCategory::Dependency,
"".repeat(MAX_INTERCEPTOR_DIAGNOSTIC_BYTES),
);
assert_eq!(error.category(), InterceptorErrorCategory::Dependency);
assert!(error.diagnostic().len() <= MAX_INTERCEPTOR_DIAGNOSTIC_BYTES);
assert!(
error
.diagnostic()
.is_char_boundary(error.diagnostic().len())
);
}
struct FailingRunExitObserver {
pause: bool,
}
#[async_trait]
impl Interceptor for FailingRunExitObserver {
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
Ok(if self.pause {
TurnEndAction::Pause
} else {
TurnEndAction::Finish
})
}
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
"terminal audit unavailable",
))
}
}
#[tokio::test]
async fn terminal_observer_failure_preserves_paused_and_interrupted_exits() {
let mut paused_engine = Engine::new(MockLlmClient::new(completed_text_events()));
paused_engine.set_interceptor(FailingRunExitObserver { pause: true });
let mut paused_history = History::new();
let mut paused_engine = paused_engine.lock(&paused_history);
assert!(matches!(
paused_engine.run(&mut paused_history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(
paused_engine
.last_run_exit_observer_failure()
.expect("paused observer diagnostic")
.error()
.category(),
InterceptorErrorCategory::Dependency
);
let mut interrupted_engine = Engine::new(MockLlmClient::new(completed_text_events()));
interrupted_engine.set_max_turns(Some(0));
interrupted_engine.set_interceptor(FailingRunExitObserver { pause: false });
let mut interrupted_history = History::new();
let mut interrupted_engine = interrupted_engine.lock(&interrupted_history);
assert!(matches!(
interrupted_engine
.run(&mut interrupted_history, "limit")
.await,
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(
interrupted_engine
.last_run_exit_observer_failure()
.expect("interrupted observer diagnostic")
.phase(),
InterceptorPoint::RunExit
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalMode {
Finish,
PauseOnce,
Yield,
}
#[derive(Debug, Clone)]
struct RecordingTerminalInterceptor {
mode: TerminalMode,
assistant_turns: Arc<AtomicUsize>,
exits: Arc<Mutex<Vec<&'static str>>>,
}
impl RecordingTerminalInterceptor {
fn new(mode: TerminalMode) -> Self {
Self {
mode,
assistant_turns: Arc::new(AtomicUsize::new(0)),
exits: Arc::new(Mutex::new(Vec::new())),
}
}
fn exits(&self) -> Vec<&'static str> {
self.exits.lock().unwrap().clone()
}
}
#[async_trait]
impl Interceptor for RecordingTerminalInterceptor {
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
Ok(if self.mode == TerminalMode::Yield {
PreRequestAction::Yield
} else {
PreRequestAction::Continue
})
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
assert!(!context.assistant_entries.is_empty());
assert!(
context.history.ends_with(context.assistant_entries),
"assistant-turn callback must observe committed terminal items"
);
let turn = self.assistant_turns.fetch_add(1, Ordering::SeqCst);
Ok(if self.mode == TerminalMode::PauseOnce && turn == 0 {
TurnEndAction::Pause
} else {
TurnEndAction::Finish
})
}
async fn on_run_exit(&self, context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
let kind = match context.exit {
EngineRunExit::Finished => "finished",
EngineRunExit::Paused => "paused",
EngineRunExit::Yielded => "yielded",
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => "limit",
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded) => "context",
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => "cancelled",
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(_)) => "unexpected",
};
self.exits.lock().unwrap().push(kind);
Ok(())
}
}
#[derive(Clone)]
struct ContextWindowClient;
#[async_trait]
impl LlmClient for ContextWindowClient {
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
Err(ClientError::ContextWindowExceeded)
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
#[tokio::test]
async fn terminal_observer_runs_once_for_every_exit_and_interruption_kind() {
let finished = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(finished.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "finish").await,
EngineRunExit::Finished
));
assert_eq!(finished.exits(), ["finished"]);
let yielded = RecordingTerminalInterceptor::new(TerminalMode::Yield);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(yielded.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "yield").await,
EngineRunExit::Yielded
));
assert_eq!(yielded.exits(), ["yielded"]);
let limited = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(0));
engine.set_interceptor(limited.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "limit").await,
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(limited.exits(), ["limit"]);
let cancelled = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(cancelled.clone());
engine.cancel();
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "cancel").await,
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
));
assert_eq!(cancelled.exits(), ["cancelled"]);
let context = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(ContextWindowClient);
engine.set_interceptor(context.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "context").await,
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded)
));
assert_eq!(context.exits(), ["context"]);
let unexpected = FailingLifecycleInterceptor::new(InterceptorPoint::PromptSubmit);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(unexpected.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "fail").await,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::Interceptor(
_
)))
));
assert_eq!(
unexpected
.calls()
.iter()
.filter(|point| **point == InterceptorPoint::RunExit)
.count(),
1
);
}
#[tokio::test]
async fn terminal_observer_does_not_duplicate_on_resume() {
let interceptor = RecordingTerminalInterceptor::new(TerminalMode::PauseOnce);
let first_response = vec![
Event::tool_use_start(0, "call-1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![first_response, completed_text_events()]);
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client);
engine.register_tool(tool.definition());
engine.set_interceptor(interceptor.clone());
let mut history = History::new();
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(interceptor.exits(), ["paused"]);
assert_eq!(
tool.call_count(),
0,
"pause must retain the pending tool phase"
);
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Finished
));
assert_eq!(interceptor.exits(), ["paused", "finished"]);
assert_eq!(
tool.call_count(),
1,
"resume must execute the retained tool once"
);
} }
#[tokio::test] #[tokio::test]
+240 -40
View File
@@ -6,13 +6,18 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo}; use agen::interceptor::{
Interceptor, InterceptorError, InterceptorErrorCategory, InterceptorPhase, InterceptorResult,
PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{ use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
ToolResultDisposition, ToolResultDisposition,
}; };
use agen::{Engine, History, Item, ToolExecutionPolicy}; use agen::{
Engine, EngineError, EngineRunExit, History, Item, RunInterruptionReason, ToolExecutionPolicy,
};
use async_trait::async_trait; use async_trait::async_trait;
mod common; mod common;
@@ -905,24 +910,30 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
#[async_trait] #[async_trait]
impl Interceptor for ContextPolicy { impl Interceptor for ContextPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
self.pre_contexts.lock().unwrap().push(info.context.clone()); self.pre_contexts.lock().unwrap().push(info.context.clone());
match info.call.name.as_str() { Ok(match info.call.name.as_str() {
"skip_tool" => PreToolAction::Skip, "skip_tool" => PreToolAction::Skip,
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output( "synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
&info.call.id, &info.call.id,
ToolOutput::from("synthetic result".to_string()), ToolOutput::from("synthetic result".to_string()),
)), )),
_ => PreToolAction::Continue, _ => PreToolAction::Continue,
} })
} }
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
self.post_contexts self.post_contexts
.lock() .lock()
.unwrap() .unwrap()
.push(info.context.clone()); .push(info.context.clone());
PostToolAction::Continue Ok(PostToolAction::Continue)
} }
} }
@@ -994,12 +1005,15 @@ async fn test_before_tool_call_skip() {
#[async_trait] #[async_trait]
impl Interceptor for BlockingPolicy { impl Interceptor for BlockingPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
if info.call.name == "blocked_tool" { &self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if info.call.name == "blocked_tool" {
PreToolAction::Skip PreToolAction::Skip
} else { } else {
PreToolAction::Continue PreToolAction::Continue
} })
} }
} }
@@ -1021,9 +1035,9 @@ async fn test_before_tool_call_skip() {
); );
} }
/// Hook: post_tool_call - verify that results can be modified /// Hook: post_tool_call - verify that the committed terminal result is observed.
#[tokio::test] #[tokio::test]
async fn test_post_tool_call_modification() { async fn test_post_tool_call_observes_committed_result() {
// Prepare responses for multiple requests // Prepare responses for multiple requests
let client = MockLlmClient::with_responses(vec![ let client = MockLlmClient::with_responses(vec![
// First request: tool call // First request: tool call
@@ -1074,40 +1088,51 @@ async fn test_post_tool_call_modification() {
engine.register_tool(simple_tool_definition()); engine.register_tool(simple_tool_definition());
// Policy to modify results // Policy to observe the committed terminal result.
struct ModifyingPolicy { struct ObservingPolicy {
modified_content: Arc<std::sync::Mutex<Option<String>>>, observed_content: Arc<std::sync::Mutex<Option<String>>>,
} }
#[async_trait] #[async_trait]
impl Interceptor for ModifyingPolicy { impl Interceptor for ObservingPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
info.result.summary = format!("[Modified] {}", info.result.summary); &self,
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone()); info: &ToolResultInfo<'_, ()>,
PostToolAction::Continue ) -> InterceptorResult<PostToolAction> {
assert_eq!(info.invocation.phase, InterceptorPhase::PostToolCall);
assert_eq!(
info.invocation.call_id,
Some(agen::InterceptorCallId::Tool(info.call.id.clone()))
);
assert!(matches!(
info.history.last().map(|entry| &entry.item),
Some(Item::ToolResult { call_id, .. }) if call_id == &info.call.id
));
*self.observed_content.lock().unwrap() = Some(info.result.summary.clone());
Ok(PostToolAction::Continue)
} }
} }
let modified_content = Arc::new(std::sync::Mutex::new(None)); let observed_content = Arc::new(std::sync::Mutex::new(None));
engine.set_interceptor(ModifyingPolicy { engine.set_interceptor(ObservingPolicy {
modified_content: modified_content.clone(), observed_content: observed_content.clone(),
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run(&mut history, "Test modification").await; let result = engine.run(&mut history, "Test observation").await;
assert!( assert!(
matches!(result.result, agen::EngineRunExit::Finished), matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete" "Engine should complete"
); );
// Verify hook was called and content was modified // Verify the interceptor observed the exact committed result.
let content = modified_content.lock().unwrap().clone(); let observed = observed_content.lock().unwrap().clone();
assert!(content.is_some(), "Hook should have been called"); assert_eq!(observed.as_deref(), Some("Original Result"));
assert!( assert!(history.items().any(|item| matches!(
content.unwrap().contains("[Modified]"), item,
"Result should be modified" Item::ToolResult { summary, .. } if summary == "Original Result"
); )));
} }
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history. /// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
@@ -1143,11 +1168,14 @@ async fn test_before_tool_call_synthetic_result_committed() {
#[async_trait] #[async_trait]
impl Interceptor for SyntheticPolicy { impl Interceptor for SyntheticPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction { async fn pre_tool_call(
PreToolAction::SyntheticResult(ToolResult::error( &self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::SyntheticResult(ToolResult::error(
info.call.id.clone(), info.call.id.clone(),
"permission denied", "permission denied",
)) )))
} }
} }
@@ -1167,6 +1195,80 @@ async fn test_before_tool_call_synthetic_result_committed() {
))); )));
} }
#[derive(Clone, Copy)]
enum InvalidIdentityMode {
ContinuedCall,
SyntheticResult,
}
struct InvalidIdentityPolicy(InvalidIdentityMode);
#[async_trait]
impl Interceptor for InvalidIdentityPolicy {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
assert_eq!(info.invocation.phase, InterceptorPhase::PreToolCall);
assert_eq!(
info.invocation.call_id,
Some(agen::InterceptorCallId::Tool("call_1".to_string()))
);
assert!(matches!(
info.history.last().map(|entry| &entry.item),
Some(Item::ToolCall { call_id, .. }) if call_id == "call_1"
));
Ok(match self.0 {
InvalidIdentityMode::ContinuedCall => {
info.call.id = "different-call".to_string();
PreToolAction::Continue
}
InvalidIdentityMode::SyntheticResult => PreToolAction::SyntheticResult(
ToolResult::error("different-call", "invalid synthetic result"),
),
})
}
}
#[tokio::test]
async fn interceptor_cannot_change_tool_call_identity() {
for mode in [
InvalidIdentityMode::ContinuedCall,
InvalidIdentityMode::SyntheticResult,
] {
let client = MockLlmClient::new(vec![
Event::tool_use_start(0, "call_1", "echo"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("echo", 1).definition());
engine.set_interceptor(InvalidIdentityPolicy(mode));
let mut history = History::new();
let result = engine.run(&mut history, "identity").await;
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(failure),
)) = result.result
else {
panic!("invalid tool identity must interrupt with a typed failure");
};
assert_eq!(failure.phase(), InterceptorPhase::PreToolCall);
assert_eq!(
failure.error().category(),
InterceptorErrorCategory::ContractViolation
);
assert!(
!history
.items()
.any(|item| matches!(item, Item::ToolResult { .. }))
);
}
}
#[tokio::test] #[tokio::test]
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() { async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let client = MockLlmClient::new(vec![ let client = MockLlmClient::new(vec![
@@ -1181,16 +1283,24 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let tool = SlowTool::new("confirmed", 1); let tool = SlowTool::new("confirmed", 1);
engine.register_tool(tool.definition()); engine.register_tool(tool.definition());
struct AbortAfterResult; let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
struct AbortAfterResult {
lifecycle: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait] #[async_trait]
impl Interceptor for AbortAfterResult { impl Interceptor for AbortAfterResult {
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction { async fn post_tool_call(
PostToolAction::Abort("policy stopped the run".to_string()) &self,
_info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
self.lifecycle.lock().unwrap().push("post_tool_call");
Ok(PostToolAction::Abort("policy stopped the run".to_string()))
} }
} }
engine.set_interceptor(AbortAfterResult); engine.set_interceptor(AbortAfterResult {
lifecycle: observed.clone(),
});
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let published = observed.clone(); let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published")); engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone(); let committed = observed.clone();
@@ -1210,7 +1320,7 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
assert_eq!(tool.call_count(), 1); assert_eq!(tool.call_count(), 1);
assert_eq!( assert_eq!(
observed.lock().unwrap().as_slice(), observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"] ["committed", "published", "post_tool_call", "run-returned"]
); );
assert!(matches!( assert!(matches!(
output.result, output.result,
@@ -1239,3 +1349,93 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
} if call_id == "call_confirmed" } if call_id == "call_confirmed"
))); )));
} }
#[derive(Clone, Copy)]
enum PostToolStopMode {
Abort,
Failure,
}
struct StopFirstParallelResult(PostToolStopMode);
#[async_trait]
impl Interceptor for StopFirstParallelResult {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
if info.call.id != "call_fast" {
return Ok(PostToolAction::Continue);
}
tokio::time::sleep(Duration::from_millis(5)).await;
match self.0 {
PostToolStopMode::Abort => Ok(PostToolAction::Abort("stop parallel batch".to_string())),
PostToolStopMode::Failure => Err(InterceptorError::new(
InterceptorErrorCategory::Policy,
"reject parallel batch",
)),
}
}
}
#[tokio::test]
async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning() {
for mode in [PostToolStopMode::Abort, PostToolStopMode::Failure] {
let first_response = vec![
Event::tool_use_start(0, "call_fast", "fast"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_ready", "ready"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let second_response = vec![
Event::text_block_start(0),
Event::text_delta(0, "next run completed"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![first_response, second_response]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("fast", 0).definition());
engine.register_tool(SlowTool::new("ready", 1).definition());
engine.set_interceptor(StopFirstParallelResult(mode));
let mut history = History::new();
let output = engine.run(&mut history, "parallel stop").await;
match mode {
PostToolStopMode::Abort => assert!(matches!(
output.result,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Aborted(ref reason)
)) if reason == "stop parallel batch"
)),
PostToolStopMode::Failure => assert!(matches!(
output.result,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(ref failure)
)) if failure.phase() == InterceptorPhase::PostToolCall
)),
}
let terminal_ids: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(terminal_ids.len(), 2);
assert!(terminal_ids.contains(&"call_fast"));
assert!(terminal_ids.contains(&"call_ready"));
let mut engine = output.engine;
let next = engine.run(&mut history, "next run").await;
assert!(matches!(next, EngineRunExit::Finished));
}
}
+286 -3
View File
@@ -7,10 +7,16 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workspace_api::{ pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
RuntimeSummary as BackendRuntimeSummary,
WorkerCapabilitySummary as BackendWorkerCapabilitySummary, WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
WorkerImplementationSummary as BackendWorkerImplementationSummary, WorkerImplementationSummary as BackendWorkerImplementationSummary,
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
WorkerOperationState as BackendWorkerOperationState,
WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
@@ -171,6 +177,47 @@ struct UploadedFileResponse {
file: protocol::UploadedFileRef, file: protocol::UploadedFileRef,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunchTarget {
pub base_url: String,
pub workspace_id: Option<String>,
}
impl BackendWorkerLaunchTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<String>) -> Self {
Self {
base_url: base_url.into(),
workspace_id,
}
}
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
self.workspace_id = Some(workspace_id.into());
}
pub fn workspace_id(&self) -> Option<&str> {
self.workspace_id.as_deref()
}
pub fn runtime_target(
&self,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
BackendRuntimeClientError::InvalidTarget(
"workspace_id is required before creating a Backend worker".to_string(),
)
})?;
Ok(BackendRuntimeTarget::new(
self.base_url.clone(),
workspace_id,
runtime_id,
worker_id,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRuntimeListTarget { pub struct BackendRuntimeListTarget {
pub base_url: String, pub base_url: String,
@@ -255,6 +302,58 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
} }
} }
pub async fn get_backend_worker_launch_options(
target: &BackendWorkerLaunchTarget,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
validate_launch_target(target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
get_backend_worker_launch_options_with_client(target, &api).await
}
async fn get_backend_worker_launch_options_with_client(
target: &BackendWorkerLaunchTarget,
api: &BackendApiClient,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
let path = backend_workspace_workers_launch_options_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
);
let response = api.request(HttpMethod::GET, &path)?.send().await?;
let response = api.require_success(response).await?;
Ok(response.json::<BackendWorkerLaunchOptions>().await?)
}
pub async fn create_backend_worker(
target: &BackendWorkerLaunchTarget,
request: &BackendCreateWorkerRequest,
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
validate_launch_target(target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
create_backend_worker_with_client(target, request, &api).await
}
async fn create_backend_worker_with_client(
target: &BackendWorkerLaunchTarget,
request: &BackendCreateWorkerRequest,
api: &BackendApiClient,
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
let path = backend_workspace_workers_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
);
let response = api
.request(HttpMethod::POST, &path)?
.json(request)
.send()
.await?;
let response = api.require_success(response).await?;
Ok(response.json::<BackendCreateWorkerResponse>().await?)
}
pub async fn list_backend_workers( pub async fn list_backend_workers(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> { ) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
@@ -462,6 +561,30 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
Ok(()) Ok(())
} }
fn validate_launch_target(
target: &BackendWorkerLaunchTarget,
) -> Result<(), BackendRuntimeClientError> {
if target.base_url.trim().is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL is required".to_string(),
));
}
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
match target.workspace_id.as_deref() {
Some("") => Err(BackendRuntimeClientError::InvalidTarget(
"workspace_id must not be empty".to_string(),
)),
None => Err(BackendRuntimeClientError::InvalidTarget(
"workspace selection is required before creating a Backend worker".to_string(),
)),
Some(_) => Ok(()),
}
}
fn validate_list_target( fn validate_list_target(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<(), BackendRuntimeClientError> { ) -> Result<(), BackendRuntimeClientError> {
@@ -496,6 +619,17 @@ fn validate_list_target(
Ok(()) Ok(())
} }
fn backend_workspace_workers_path(workspace_id: &str) -> String {
format!("/api/w/{}/workers", path_segment_encode(workspace_id))
}
fn backend_workspace_workers_launch_options_path(workspace_id: &str) -> String {
format!(
"{}/launch-options",
backend_workspace_workers_path(workspace_id)
)
}
fn backend_runtimes_path(workspace_id: &str) -> String { fn backend_runtimes_path(workspace_id: &str) -> String {
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)) format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
} }
@@ -580,6 +714,155 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve_json_once(body: serde_json::Value) -> (String, tokio::task::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let header_end = loop {
let mut buffer = [0_u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "client closed before sending HTTP headers");
request.extend_from_slice(&buffer[..read]);
if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") {
break position + 4;
}
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
while request.len() < header_end + content_length {
let mut buffer = [0_u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "client closed before sending HTTP body");
request.extend_from_slice(&buffer[..read]);
}
let body = serde_json::to_vec(&body).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
String::from_utf8(request).unwrap()
});
(base_url, task)
}
#[tokio::test]
async fn launch_options_request_uses_workspace_path_and_bearer_auth() {
let (base_url, server) = serve_json_once(serde_json::json!({
"workspace_id": "team main",
"runtimes": [{
"runtime_id": "embedded",
"display_name": "Embedded",
"built_in": true,
"worker_creation_available": true,
"working_directory_required": false,
"status": "online",
"diagnostics": []
}],
"default_profile": "builtin:default",
"profiles": [{
"id": "builtin:default",
"label": "Default",
"description": ""
}],
"repositories": [],
"working_directories": [],
"diagnostics": []
}))
.await;
let target = BackendWorkerLaunchTarget::new(&base_url, Some("team main".to_string()));
let api = BackendApiClient::from_access_token_for_test(&base_url, "launch-secret").unwrap();
let response = get_backend_worker_launch_options_with_client(&target, &api)
.await
.unwrap();
assert_eq!(response.runtimes[0].runtime_id, "embedded");
let request = server.await.unwrap();
assert!(request.starts_with("GET /api/w/team%20main/workers/launch-options HTTP/1.1\r\n"));
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer launch-secret\r\n")
);
}
#[tokio::test]
async fn create_worker_posts_frontend_contract_to_workspace_path() {
let (base_url, server) = serve_json_once(serde_json::json!({
"workspace_id": "workspace-1",
"runtime_id": "embedded",
"worker_id": "worker-1",
"console_href": "/w/workspace-1/workers/embedded/worker-1",
"worker": {
"runtime_id": "embedded",
"worker_id": "worker-1",
"host_id": "host-1",
"display_name": "Coder one",
"label": "Coder one",
"profile": "builtin:coder",
"singleton_key": null,
"tags": [],
"workspace": {
"visibility": "workspace",
"identity": "workspace",
"workspace_id": "workspace-1"
},
"state": "idle",
"last_seen_at": null,
"pinned": false,
"retention_state": "resident",
"implementation": {"kind": "embedded", "display_hint": "Embedded"},
"capabilities": {"can_stop": true, "can_spawn_followup": false},
"diagnostics": []
},
"diagnostics": []
}))
.await;
let target = BackendWorkerLaunchTarget::new(&base_url, Some("workspace-1".to_string()));
let api = BackendApiClient::from_access_token_for_test(&base_url, "create-secret").unwrap();
let create = BackendCreateWorkerRequest {
runtime_id: "embedded".to_string(),
display_name: "Coder one".to_string(),
profile: Some("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory: None,
control_operation_id: None,
};
let response = create_backend_worker_with_client(&target, &create, &api)
.await
.unwrap();
assert_eq!(response.worker_id, "worker-1");
let request = server.await.unwrap();
assert!(request.starts_with("POST /api/w/workspace-1/workers HTTP/1.1\r\n"));
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer create-secret\r\n")
);
let body = request.split_once("\r\n\r\n").unwrap().1;
let body: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(body["runtime_id"], "embedded");
assert_eq!(body["display_name"], "Coder one");
assert_eq!(body["profile"], "builtin:coder");
assert_eq!(body["initial_submit"], serde_json::json!([]));
assert_eq!(body["working_directory"], serde_json::Value::Null);
}
#[test] #[test]
fn protocol_url_uses_backend_runtime_worker_identity() { fn protocol_url_uses_backend_runtime_worker_identity() {
@@ -631,7 +914,7 @@ mod tests {
"working_directory": { "working_directory": {
"working_directory_id": "wd-1", "working_directory_id": "wd-1",
"repository_key": "main", "repository_key": "main",
"materializer_kind": "local_git_worktree", "materializer_kind": "runtime_git_clone",
"status": "active", "status": "active",
"occupied_by": { "occupied_by": {
"runtime_id": "arcadia", "runtime_id": "arcadia",
+47 -20
View File
@@ -1,9 +1,9 @@
use crate::{BackendApiClient, BackendApiClientError}; use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{ use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord, InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
WorkspaceSummary, WorkspaceSummary,
}; };
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
pub type BackendWorkspace = WorkspaceSummary; pub type BackendWorkspace = WorkspaceSummary;
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse; pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord; pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRequest {
pub operation_key: String,
pub display_name: String,
pub repository: CreateBackendWorkspaceRepository,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateBackendWorkspaceRepository {
pub uri: String,
pub display_name: Option<String>,
pub default_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget { pub struct BackendWorkspaceCatalogTarget {
@@ -73,6 +58,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
} }
} }
pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
}
pub fn list_backend_workspace_repositories_blocking(
target: &BackendWorkspaceCatalogTarget,
workspace_id: &str,
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
if workspace_id.is_empty()
|| workspace_id.len() > 200
|| !workspace_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(BackendWorkspaceClientError::InvalidTarget(
"Workspace id returned by Backend is invalid".to_string(),
));
}
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
.send()?;
client.check_status(response.status())?;
let response = response.json::<RepositoryListResponse>()?;
if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(),
));
}
Ok(response.items)
}
pub async fn list_backend_workspaces( pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
@@ -149,8 +176,8 @@ mod tests {
operation_key: "workspace-create-1".to_string(), operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(), display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(), uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()), default_ref: Some("develop".to_string()),
}, },
}; };
+8 -7
View File
@@ -112,26 +112,27 @@ mod tests {
async fn encodes_methods_and_decodes_events_above_transport() { async fn encodes_methods_and_decodes_events_above_transport() {
let mut socket = TestSocket::default(); let mut socket = TestSocket::default();
socket.incoming.push_back( socket.incoming.push_back(
encode_event(&Event::Status { encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.expect("encode event"), .expect("encode event"),
); );
let mut client = Client::new(socket); let mut client = Client::new(socket);
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
assert!(matches!( assert!(matches!(
decode_method(&client.socket.sent[0]), decode_method(&client.socket.sent[0]),
Ok(Method::Run { .. }) Ok(Method::Submit { .. })
)); ));
assert!(matches!( assert!(matches!(
client.next_event().await, client.next_event().await,
Ok(Some(Event::Status { Ok(Some(Event::WorkerState { .. }))
status: WorkerStatus::Idle
}))
)); ));
} }
} }
+16 -10
View File
@@ -21,23 +21,29 @@ pub use backend_auth::{
poll_device_login, start_device_login, wait_for_device_login, poll_device_login, start_device_login, wait_for_device_login,
}; };
pub use backend_runtime::{ pub use backend_runtime::{
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError, BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic,
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker, BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
}; };
pub use backend_workspace::{ pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, CreateBackendWorkspaceResponse, create_backend_workspace,
list_backend_workspace_repositories_blocking, list_backend_workspaces,
list_backend_workspaces_blocking,
}; };
pub use client::{Client, ClientError}; pub use client::{Client, ClientError};
pub use target::{ pub use target::{
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent, BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection, StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
}; };
pub use workspace_api::{ pub use workspace_api::{
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition, CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
+22 -1
View File
@@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf};
use crate::{ use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget, BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget, BackendRuntimeTarget, BackendWorkerLaunchTarget,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -123,6 +123,11 @@ pub struct Dashboard {
pub workspace_id: String, pub workspace_id: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunch {
pub target: BackendWorkerLaunchTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList { pub struct WorkerList {
pub backend_target: BackendRuntimeListTarget, pub backend_target: BackendRuntimeListTarget,
@@ -199,6 +204,13 @@ pub trait Target: fmt::Debug + Send + Sync {
Err(TargetError::unsupported("Worker dashboard", self.kind())) Err(TargetError::unsupported("Worker dashboard", self.kind()))
} }
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Err(TargetError::unsupported(
"Backend Worker launch",
self.kind(),
))
}
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported("Worker listing", self.kind())) Err(TargetError::unsupported("Worker listing", self.kind()))
} }
@@ -299,6 +311,15 @@ impl Target for BackendTarget {
}) })
} }
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Ok(BackendWorkerLaunch {
target: BackendWorkerLaunchTarget::new(
self.base_url.clone(),
self.workspace_id.clone(),
),
})
}
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList { Ok(WorkerList {
backend_target: BackendRuntimeListTarget::new( backend_target: BackendRuntimeListTarget::new(
+8 -7
View File
@@ -89,17 +89,20 @@ mod tests {
let mut client = Client::new(socket); let mut client = Client::new(socket);
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
assert!(matches!( assert!(matches!(
peer.next().await.as_deref().map(decode_method), peer.next().await.as_deref().map(decode_method),
Some(Ok(Method::Run { .. })) Some(Ok(Method::Submit { .. }))
)); ));
peer.send( peer.send(
encode_event(&Event::Status { encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.expect("encode event"), .expect("encode event"),
) )
@@ -107,9 +110,7 @@ mod tests {
.expect("send event"); .expect("send event");
assert!(matches!( assert!(matches!(
client.next_event().await, client.next_event().await,
Ok(Some(Event::Status { Ok(Some(Event::WorkerState { .. }))
status: WorkerStatus::Idle
}))
)); ));
} }
} }
+11 -10
View File
@@ -113,8 +113,8 @@ mod tests {
let listener = UnixListener::bind(&socket_path).unwrap(); let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move { let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap(); let (mut stream, _) = listener.accept().await.unwrap();
let event = encode_event(&Event::Status { let event = encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.unwrap(); .unwrap();
stream.write_all(event.as_bytes()).await.unwrap(); stream.write_all(event.as_bytes()).await.unwrap();
@@ -126,12 +126,7 @@ mod tests {
.await .await
.expect("client should receive event while alive") .expect("client should receive event while alive")
.expect("transport should succeed"); .expect("transport should succeed");
assert!(matches!( assert!(matches!(event, Some(Event::WorkerState { .. })));
event,
Some(Event::Status {
status: WorkerStatus::Idle
})
));
server.await.unwrap(); server.await.unwrap();
} }
@@ -147,12 +142,18 @@ mod tests {
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap()); let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
let received = server.await.unwrap().expect("method message"); let received = server.await.unwrap().expect("method message");
assert!(matches!(decode_method(&received), Ok(Method::Run { .. }))); assert!(matches!(
decode_method(&received),
Ok(Method::Submit { .. })
));
} }
#[tokio::test] #[tokio::test]
+8 -7
View File
@@ -114,10 +114,10 @@ mod tests {
assert!(matches!( assert!(matches!(
message, message,
Message::Text(ref text) Message::Text(ref text)
if matches!(decode_method(text), Ok(Method::Run { .. })) if matches!(decode_method(text), Ok(Method::Submit { .. }))
)); ));
let event = encode_event(&Event::Status { let event = encode_event(&Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}) })
.unwrap(); .unwrap();
socket.send(Message::Text(event.into())).await.unwrap(); socket.send(Message::Text(event.into())).await.unwrap();
@@ -126,14 +126,15 @@ mod tests {
let request = format!("ws://{address}").into_client_request().unwrap(); let request = format!("ws://{address}").into_client_request().unwrap();
let mut client = Client::new(Socket::connect(request).await.unwrap()); let mut client = Client::new(Socket::connect(request).await.unwrap());
client client
.send(&Method::run_text("hello")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await .await
.expect("send method"); .expect("send method");
assert!(matches!( assert!(matches!(
client.next_event().await, client.next_event().await,
Ok(Some(Event::Status { Ok(Some(Event::WorkerState { .. }))
status: WorkerStatus::Idle
}))
)); ));
server.await.unwrap(); server.await.unwrap();
} }
+39 -2
View File
@@ -12,8 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
}; };
use crate::{BackendApiClient, BackendWorkspaceClientError}; use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -241,6 +243,41 @@ impl BackendWorkspaceProductClient {
) )
} }
pub fn list_runtimes(
&self,
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
self.get_json("/runtimes")
}
pub fn runtime_detail(
&self,
runtime_id: &str,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
}
pub fn reveal_runtime_trust_key(
&self,
runtime_id: &str,
) -> Result<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
self.get_json(&format!(
"/runtimes/{}/trust-key",
encode_path_segment(runtime_id)
))
}
pub fn revoke_runtime_trust_key(
&self,
runtime_id: &str,
request: &RevokeRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::DELETE,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> { pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
self.get_json("/memory") self.get_json("/memory")
} }
+8 -1
View File
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
operation_key: "workspace-create-1".to_string(), operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(), display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(), uri: "/srv/repos/alpha".to_string(),
display_name: Some("Main".to_string()),
default_ref: Some("develop".to_string()), default_ref: Some("develop".to_string()),
}, },
}; };
assert_eq!(request.clone(), request); assert_eq!(request.clone(), request);
assert_eq!(request.operation_key, "workspace-create-1"); assert_eq!(request.operation_key, "workspace-create-1");
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["repository_key"], "main");
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
assert!(json.get("operation_id").is_none());
assert!(json["repository"].get("display_name").is_none());
assert!(json["repository"].get("source").is_none());
} }
#[test] #[test]
+12 -14
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use globset::Glob; use globset::Glob;
use ignore::WalkBuilder; use ignore::WalkBuilder;
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink}; use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
/// Execute a bounded glob entirely inside the provider process. /// Execute a bounded glob entirely inside the provider process.
pub fn run_glob( pub fn run_glob(
@@ -15,26 +15,24 @@ pub fn run_glob(
if !root.is_absolute() { if !root.is_absolute() {
return Err(FsError::RelativePath(root.to_path_buf())); return Err(FsError::RelativePath(root.to_path_buf()));
} }
if !access.is_readable(base) { let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
path: PathBuf::from(request.path.as_str()),
source: error,
})?;
if !access.is_readable_paths(base, &base_resolved) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str()))); return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
} }
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) let matcher = Glob::new(&request.pattern)
.map_err(|error| FsError::InvalidGlob(error.to_string()))? .map_err(|error| FsError::InvalidGlob(error.to_string()))?
.compile_matcher(); .compile_matcher();
let mut matches = Vec::new(); let mut matches = Vec::new();
for entry in WalkBuilder::new(base).hidden(false).build().flatten() { let mut walker = WalkBuilder::new(base);
walker.hidden(false).follow_links(false);
for entry in walker.build().flatten() {
let path = entry.path(); let path = entry.path();
if !path.is_file() || !access.is_readable(path) { let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !path.is_file() || !readable {
continue; continue;
} }
let relative = path.strip_prefix(base).unwrap_or(path); let relative = path.strip_prefix(base).unwrap_or(path);
+40 -9
View File
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
pub use glob::run_glob; pub use glob::run_glob;
pub use local::{run_edit, run_list, run_read, run_stat, run_write}; pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
pub use operation::*; pub use operation::*;
pub use search::run_grep; pub use search::run_grep;
@@ -22,6 +22,19 @@ pub use search::run_grep;
pub trait FsAccessPolicy: Send + Sync { pub trait FsAccessPolicy: Send + Sync {
fn is_readable(&self, path: &Path) -> bool; fn is_readable(&self, path: &Path) -> bool;
fn is_writable(&self, path: &Path) -> bool; fn is_writable(&self, path: &Path) -> bool;
/// Authorize both the Workdir-visible path and its provider-resolved
/// target. Implementations that do not distinguish symbolic-link identity
/// retain resolved-target semantics through the defaults.
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_readable(resolved)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
let _ = logical;
self.is_writable(resolved)
}
} }
/// First symlink encountered while resolving a provider path. /// First symlink encountered while resolving a provider path.
@@ -477,13 +490,14 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() { fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap(); let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone()); let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap(); std::fs::create_dir(root.join("target-dir")).unwrap();
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap(); std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap(); symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).unwrap(); symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
@@ -501,18 +515,35 @@ mod tests {
assert_eq!(file_result.match_count, 1); assert_eq!(file_result.match_count, 1);
assert!(file_result.output.starts_with("file-link.rs\n")); assert!(file_result.output.starts_with("file-link.rs\n"));
let directory_error = run_grep( let directory_result = run_grep(
&root, &root,
root.join("directory-link"), root.join("directory-link"),
request("directory-link"), request("directory-link"),
&readable, &readable,
) )
.unwrap_err(); .unwrap();
assert!(matches!( assert_eq!(directory_result.match_count, 1);
directory_error, assert!(
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. } directory_result
if path == root.join("directory-link") .output
)); .starts_with("directory-link/nested.rs\n")
);
let glob_result = run_glob(
&root,
&root.join("directory-link"),
GlobRequest {
pattern: "**/*.rs".to_string(),
path: FsPath::new("directory-link").unwrap(),
limit: 10,
},
&readable,
)
.unwrap();
assert_eq!(
glob_result.paths,
vec![FsPath::new("directory-link/nested.rs").unwrap()]
);
let broken_error = run_grep( let broken_error = run_grep(
&root, &root,
+72 -34
View File
@@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -18,7 +19,8 @@ pub fn run_stat(
) -> Result<StatResult, FsError> { ) -> Result<StatResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
if !access.is_readable(&path) { let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
if !access.is_readable_paths(&path, &resolved) {
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str()))); return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
} }
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
@@ -45,7 +47,7 @@ pub fn run_read(
) -> Result<ReadResult, FsError> { ) -> Result<ReadResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?; let path = require_access(&path, &logical, access, false, false)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -99,7 +101,7 @@ pub fn run_write(
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let created = !path.exists(); let created = !path.exists();
if path.exists() { if path.exists() {
let target = require_access(&path, &logical, access, true)?; let target = require_access(&path, &logical, access, true, false)?;
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?; let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -113,12 +115,8 @@ pub fn run_write(
if request.expected_hash.is_some() { if request.expected_hash.is_some() {
return Err(FsError::Conflict(logical.as_str().to_string())); return Err(FsError::Conflict(logical.as_str().to_string()));
} }
let parent = path.parent().ok_or_else(|| { let target = require_access(&path, &logical, access, true, true)?;
FsError::InvalidArgument(format!("{} has no parent", logical.as_str())) atomic_write(&target, &request.content, &logical)?;
})?;
let parent_logical = logical_parent(&logical);
require_access(parent, &parent_logical, access, true)?;
atomic_write(&path, &request.content, &logical)?;
} }
Ok(WriteResult { Ok(WriteResult {
bytes_written: request.content.len(), bytes_written: request.content.len(),
@@ -133,7 +131,7 @@ pub fn run_edit(
) -> Result<EditResult, FsError> { ) -> Result<EditResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let target = require_access(&path, &logical, access, true)?; let target = require_access(&path, &logical, access, true, false)?;
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?; let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
let actual_hash = hash_bytes(&bytes); let actual_hash = hash_bytes(&bytes);
if actual_hash != request.expected_hash { if actual_hash != request.expected_hash {
@@ -173,7 +171,8 @@ pub fn run_list(
) -> Result<ListResult, FsError> { ) -> Result<ListResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?; let logical_base = path.clone();
let path = require_access(&path, &logical, access, false, true)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() { if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
@@ -183,7 +182,15 @@ pub fn run_list(
for entry in read_dir { for entry in read_dir {
let entry = entry.map_err(|error| map_io(&logical, error))?; let entry = entry.map_err(|error| map_io(&logical, error))?;
let absolute = entry.path(); let absolute = entry.path();
if !access.is_readable(&absolute) { let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
})?;
let logical_absolute = logical_base.join(relative_to_base);
let resolved = match resolve_access_path(&absolute) {
Ok(resolved) => resolved,
Err(_) => continue,
};
if !access.is_readable_paths(&logical_absolute, &resolved) {
continue; continue;
} }
let link_metadata = let link_metadata =
@@ -203,7 +210,7 @@ pub fn run_list(
} else { } else {
EntryKind::Other EntryKind::Other
}; };
let relative = absolute.strip_prefix(root).map_err(|_| { let relative = logical_absolute.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string()) FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?; })?;
entries.push(ListEntry { entries.push(ListEntry {
@@ -247,19 +254,24 @@ fn require_access(
logical: &FsPath, logical: &FsPath,
access: &dyn FsAccessPolicy, access: &dyn FsAccessPolicy,
write: bool, write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> { ) -> Result<PathBuf, FsError> {
if let Some(info) = direct_symlink(path) { let symlink = direct_symlink(path);
if !info.target_exists { if let Some(info) = symlink.as_ref()
return Err(FsError::BrokenSymlink { && !info.target_exists
path: PathBuf::from(logical.as_str()), {
link: PathBuf::from(logical.as_str()), return Err(FsError::BrokenSymlink {
target: PathBuf::from("<provider-internal target>"), path: PathBuf::from(logical.as_str()),
}); link: PathBuf::from(logical.as_str()),
} target: PathBuf::from("<provider-internal target>"),
});
}
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
if let Some(info) = symlink {
let allowed = if write { let allowed = if write {
access.is_writable(&info.resolved_path) access.is_writable_paths(path, &resolved)
} else { } else {
access.is_readable(&info.resolved_path) access.is_readable_paths(path, &resolved)
}; };
if !allowed { if !allowed {
return Err(FsError::SymlinkOutOfScope { return Err(FsError::SymlinkOutOfScope {
@@ -268,21 +280,21 @@ fn require_access(
required_permission: if write { "write" } else { "read" }, required_permission: if write { "write" } else { "read" },
}); });
} }
if write && info.resolved_path.is_dir() { if !allow_symlink_directory && info.resolved_path.is_dir() {
return Err(FsError::SymlinkTargetIsDirectory { return Err(FsError::SymlinkTargetIsDirectory {
path: PathBuf::from(logical.as_str()), path: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"), target: PathBuf::from("<provider-internal target>"),
}); });
} }
return Ok(info.resolved_path); return Ok(resolved);
} }
let allowed = if write { let allowed = if write {
access.is_writable(path) access.is_writable_paths(path, &resolved)
} else { } else {
access.is_readable(path) access.is_readable_paths(path, &resolved)
}; };
if allowed { if allowed {
Ok(path.to_path_buf()) Ok(resolved)
} else if write { } else if write {
Err(FsError::ReadOnly(PathBuf::from(logical.as_str()))) Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
} else { } else {
@@ -290,12 +302,38 @@ fn require_access(
} }
} }
fn logical_parent(path: &FsPath) -> FsPath { /// Resolve every existing component of an absolute provider path while
let parent = Path::new(path.as_str()) /// retaining a missing final tail for create operations. Dangling symlinks are
.parent() /// rejected because no resolved authority identity can be established.
.unwrap_or_else(|| Path::new("")) pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
.to_string_lossy(); let mut cursor = path;
FsPath::new(parent).unwrap_or_else(|_| FsPath::root()) let mut missing = Vec::<OsString>::new();
loop {
match fs::canonicalize(cursor) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return Ok(resolved);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if fs::symlink_metadata(cursor)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(error);
}
let name = cursor.file_name().ok_or(error)?;
missing.push(name.to_os_string());
cursor = cursor.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"path has no existing ancestor",
)
})?;
}
Err(error) => return Err(error),
}
}
} }
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> { fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
+23 -24
View File
@@ -10,7 +10,9 @@ use ignore::WalkBuilder;
use ignore::overrides::{Override, OverrideBuilder}; use ignore::overrides::{Override, OverrideBuilder};
use ignore::types::{Types, TypesBuilder}; use ignore::types::{Types, TypesBuilder};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink}; use crate::{
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
};
struct ContentLine { struct ContentLine {
path: PathBuf, path: PathBuf,
@@ -220,14 +222,28 @@ pub fn run_grep(
return Err(FsError::RelativePath(base)); return Err(FsError::RelativePath(base));
} }
let symlink = direct_symlink(&base); let symlink = direct_symlink(&base);
if !access.is_readable(&base) { if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.resolved_path.clone(),
});
}
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
if !access.is_readable_paths(&base, &resolved_base) {
return Err(if let Some(info) = symlink.as_ref() { return Err(if let Some(info) = symlink.as_ref() {
let link_parent_readable = info let link_parent_readable = info
.link_path .link_path
.parent() .parent()
.map(|parent| access.is_readable(parent)) .and_then(|parent| {
resolve_access_path(parent)
.ok()
.map(|resolved| access.is_readable_paths(parent, &resolved))
})
.unwrap_or(false); .unwrap_or(false);
if info.target_exists && link_parent_readable { if link_parent_readable {
FsError::SymlinkOutOfScope { FsError::SymlinkOutOfScope {
path: base.clone(), path: base.clone(),
target: info.resolved_path.clone(), target: info.resolved_path.clone(),
@@ -240,15 +256,6 @@ pub fn run_grep(
FsError::OutOfScope(base.clone()) FsError::OutOfScope(base.clone())
}); });
} }
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.target_path.clone(),
});
}
}
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() { let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e), _ => FsError::io(&base, e),
@@ -259,16 +266,6 @@ pub fn run_grep(
base.display() base.display()
))); )));
} }
if base_meta.is_dir()
&& let Some(info) = symlink.as_ref()
{
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Grep",
path: base.clone(),
target: info.resolved_path.clone(),
});
}
let filter_base = if base_meta.is_file() { root } else { &base }; let filter_base = if base_meta.is_file() { root } else { &base };
let types = build_types(p.file_type.as_deref())?; let types = build_types(p.file_type.as_deref())?;
let overrides = build_overrides(filter_base, p.glob.as_deref())?; let overrides = build_overrides(filter_base, p.glob.as_deref())?;
@@ -331,7 +328,9 @@ pub fn run_grep(
continue; continue;
} }
let path = entry.path(); let path = entry.path();
if !access.is_readable(path) { let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !readable {
continue; continue;
} }
if scan_path( if scan_path(
+187 -101
View File
@@ -15,13 +15,13 @@ use serde::{Deserialize, Serialize};
use crate::defaults; use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig, MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig, MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
WorkerManifest, WorkerMeta, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
}; };
/// Partial-form Worker manifest. Every field is optional; one or more /// Partial-form Worker manifest. Every field is optional; one or more
@@ -54,10 +54,6 @@ pub struct WorkerManifestConfig {
/// disabled after cascade merge. /// disabled after cascade merge.
#[serde(default)] #[serde(default)]
pub feature: FeatureConfigPartial, pub feature: FeatureConfigPartial,
/// Explicit plugin package enablement entries. Discovery/resolution is a
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
/// Explicit Model Context Protocol provider declarations. Config parsing /// Explicit Model Context Protocol provider declarations. Config parsing
/// never starts a local MCP subprocess. /// never starts a local MCP subprocess.
#[serde(default)] #[serde(default)]
@@ -67,15 +63,13 @@ pub struct WorkerManifestConfig {
/// First-class web tool opt-in. See [`WebConfig`]. /// First-class web tool opt-in. See [`WebConfig`].
#[serde(default)] #[serde(default)]
pub web: Option<WebConfig>, pub web: Option<WebConfig>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills directories. See [`crate::SkillsConfig`]. /// External Agent Skills directories. See [`crate::SkillsConfig`].
#[serde(default)] #[serde(default)]
pub skills: Option<SkillsConfig>, pub skills: Option<SkillsConfig>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfigPartial { pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub task: Option<FeatureFlagConfigPartial>, pub task: Option<FeatureFlagConfigPartial>,
@@ -103,8 +97,6 @@ pub struct FeatureConfigPartial {
pub merge_request: Option<MergeRequestFeatureConfigPartial>, pub merge_request: Option<MergeRequestFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>, pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
} }
impl FeatureConfigPartial { impl FeatureConfigPartial {
@@ -147,7 +139,6 @@ impl FeatureConfigPartial {
other.orchestration, other.orchestration,
FeatureFlagConfigPartial::merge, FeatureFlagConfigPartial::merge,
), ),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
} }
} }
} }
@@ -193,18 +184,86 @@ impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryFeatureConfigPartial { pub struct MemoryFeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub enabled: Option<bool>, pub enabled: Option<bool>,
#[serde(default)] #[serde(default)]
pub staging: Option<bool>, pub staging_tools: Option<bool>,
#[serde(default)]
pub resident: Option<MemoryResidentProfileConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
#[serde(default)]
pub consolidation: Option<MemoryConsolidationProfileConfigPartial>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryResidentProfileConfigPartial {
#[serde(default)]
pub inject_summary: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryExtractionProfileConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub model: Option<ModelManifest>,
#[serde(default)]
pub threshold: Option<u64>,
#[serde(default)]
pub worker_max_turns: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidationProfileConfigPartial {
#[serde(default)]
pub request_enabled: Option<bool>,
} }
impl MemoryFeatureConfigPartial { impl MemoryFeatureConfigPartial {
fn merge(self, other: Self) -> Self { fn merge(self, other: Self) -> Self {
Self { Self {
enabled: other.enabled.or(self.enabled), enabled: other.enabled.or(self.enabled),
staging: other.staging.or(self.staging), staging_tools: other.staging_tools.or(self.staging_tools),
resident: merge_option(
self.resident,
other.resident,
MemoryResidentProfileConfigPartial::merge,
),
extraction: merge_option(
self.extraction,
other.extraction,
MemoryExtractionProfileConfigPartial::merge,
),
consolidation: merge_option(
self.consolidation,
other.consolidation,
MemoryConsolidationProfileConfigPartial::merge,
),
}
}
}
impl MemoryResidentProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
inject_summary: other.inject_summary.or(self.inject_summary),
}
}
}
impl MemoryExtractionProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
model: other.model.or(self.model),
threshold: other.threshold.or(self.threshold),
worker_max_turns: other.worker_max_turns.or(self.worker_max_turns),
} }
} }
} }
@@ -253,13 +312,21 @@ impl MergeRequestFeatureConfigPartial {
} }
} }
impl MemoryConsolidationProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
request_enabled: other.request_enabled.or(self.request_enabled),
}
}
}
impl From<FeatureConfigPartial> for FeatureConfig { impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self { fn from(value: FeatureConfigPartial) -> Self {
Self { Self {
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(), task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
memory: value memory: value
.memory .memory
.map(MemoryFeatureConfig::from) .map(ResolvedMemoryFeatureConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(), web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(), image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
@@ -296,10 +363,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.orchestration .orchestration
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
} }
} }
} }
@@ -329,20 +392,52 @@ impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
} }
} }
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig { impl From<MemoryFeatureConfigPartial> for ResolvedMemoryFeatureConfig {
fn from(value: MemoryFeatureConfigPartial) -> Self { fn from(value: MemoryFeatureConfigPartial) -> Self {
let resident = value.resident.unwrap_or_default();
let extraction = value.extraction.unwrap_or_default();
let consolidation = value.consolidation.unwrap_or_default();
Self { Self {
enabled: value.enabled.unwrap_or_default(), profile: MemoryFeatureProfileConfig {
staging: value.staging.unwrap_or_default(), 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 { impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: MemoryFeatureConfig) -> Self { fn from(value: ResolvedMemoryFeatureConfig) -> Self {
Self { Self {
enabled: Some(value.enabled), enabled: Some(value.profile.enabled),
staging: Some(value.staging), staging_tools: Some(value.profile.staging_tools),
resident: Some(MemoryResidentProfileConfigPartial {
inject_summary: Some(value.profile.resident.inject_summary),
}),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
threshold: value.profile.extraction.threshold,
worker_max_turns: value.profile.extraction.worker_max_turns,
}),
consolidation: Some(MemoryConsolidationProfileConfigPartial {
request_enabled: Some(value.profile.consolidation.request_enabled),
}),
} }
} }
} }
@@ -411,7 +506,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()), merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()), orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()),
} }
} }
} }
@@ -543,13 +637,23 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
(removed; use compaction.prune_protected_tokens)", (removed; use compaction.prune_protected_tokens)",
)); ));
} }
if value.get("memory").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: memory (removed; configure feature.memory)",
));
}
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value if value
.get("memory") .get("feature")
.and_then(toml::Value::as_table) .and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens")) .is_some_and(|table| table.contains_key("plugins"))
{ {
return Err(toml::de::Error::custom( return Err(toml::de::Error::custom(
"unknown field in manifest: memory.extract_worker_max_input_tokens (removed)", "unknown field in manifest: feature.plugins (dynamic Plugins are not supported)",
)); ));
} }
if value if value
@@ -633,11 +737,6 @@ impl WorkerManifestConfig {
for rule in &mut self.delegation_scope.deny { for rule in &mut self.delegation_scope.deny {
rule.target = join_if_relative(base, &rule.target); rule.target = join_if_relative(base, &rule.target);
} }
if let Some(ref mut memory) = self.memory
&& let Some(ref mut root) = memory.workspace_root
{
*root = join_if_relative(base, root);
}
if let Some(ref mut compaction) = self.compaction if let Some(ref mut compaction) = self.compaction
&& let Some(ref mut cp) = compaction.model && let Some(ref mut cp) = compaction.model
{ {
@@ -674,7 +773,6 @@ impl WorkerManifestConfig {
PermissionConfigPartial::merge, PermissionConfigPartial::merge,
), ),
feature: self.feature.merge(upper.feature), feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
mcp: merge_mcp_config(self.mcp, upper.mcp), mcp: merge_mcp_config(self.mcp, upper.mcp),
compaction: merge_option( compaction: merge_option(
self.compaction, self.compaction,
@@ -682,7 +780,6 @@ impl WorkerManifestConfig {
CompactionConfigPartial::merge, CompactionConfigPartial::merge,
), ),
web: merge_option(self.web, upper.web, WebConfig::merge), web: merge_option(self.web, upper.web, WebConfig::merge),
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge), skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
} }
} }
@@ -695,16 +792,6 @@ impl SkillsConfig {
} }
} }
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
let upper_has_resolved_plan = upper.has_resolved_plan();
base.enabled.extend(upper.enabled);
if upper_has_resolved_plan {
base.resolved = upper.resolved;
base.diagnostics = upper.diagnostics;
}
base
}
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig { fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
base.stdio_servers.extend(upper.stdio_servers); base.stdio_servers.extend(upper.stdio_servers);
base base
@@ -754,32 +841,6 @@ impl crate::WebFetchConfig {
} }
} }
impl MemoryConfig {
fn merge(self, upper: Self) -> Self {
Self {
workspace_root: upper.workspace_root.or(self.workspace_root),
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
inject_summary: upper.inject_summary.or(self.inject_summary),
workspace_id: upper.workspace_id.or(self.workspace_id),
settings_revision: upper.settings_revision.or(self.settings_revision),
language: upper.language.or(self.language),
extract_model: upper.extract_model.or(self.extract_model),
extract_threshold: upper.extract_threshold.or(self.extract_threshold),
extract_worker_max_turns: upper
.extract_worker_max_turns
.or(self.extract_worker_max_turns),
consolidation_model: upper.consolidation_model.or(self.consolidation_model),
consolidation_threshold_files: upper
.consolidation_threshold_files
.or(self.consolidation_threshold_files),
consolidation_threshold_bytes: upper
.consolidation_threshold_bytes
.or(self.consolidation_threshold_bytes),
}
}
}
impl WorkerMetaConfig { impl WorkerMetaConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> Self {
Self { Self {
@@ -1219,11 +1280,9 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
session, session,
permissions, permissions,
feature: FeatureConfig::from(cfg.feature), feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
mcp: cfg.mcp, mcp: cfg.mcp,
compaction, compaction,
web: cfg.web, web: cfg.web,
memory: cfg.memory,
skills: cfg.skills, skills: cfg.skills,
profile: None, profile: None,
}) })
@@ -1260,18 +1319,17 @@ mod tests {
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
delegation_scope: ScopeConfig::default(), delegation_scope: ScopeConfig::default(),
permissions: None, permissions: None,
feature: FeatureConfigPartial::default(), feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
mcp: McpConfig::default(), mcp: McpConfig::default(),
session: None, session: None,
compaction: None, compaction: None,
web: None, web: None,
memory: None,
skills: None, skills: None,
} }
} }
@@ -1507,6 +1565,7 @@ mod tests {
target: PathBuf::from("secrets"), target: PathBuf::from("secrets"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
let resolved = cfg.resolve_paths(Path::new("/workspace/proj")); let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj")); assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
@@ -1644,6 +1703,7 @@ mod tests {
target: abs("/a"), target: abs("/a"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -1655,11 +1715,13 @@ mod tests {
target: abs("/b"), target: abs("/b"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: abs("/a/secret"), target: abs("/a/secret"),
permission: Permission::Read, permission: Permission::Read,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}], }],
}, },
..Default::default() ..Default::default()
@@ -1846,29 +1908,50 @@ prune_protected_turns = 3
} }
#[test] #[test]
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() { fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
let bad = r#" let cfg = WorkerManifestConfig::from_toml(
[memory] r#"
extract_worker_max_input_tokens = 30000 [feature.memory]
"#; enabled = true
let err = WorkerManifestConfig::from_toml(bad).unwrap_err(); staging_tools = false
assert!(
err.to_string() [feature.memory.resident]
.contains("memory.extract_worker_max_input_tokens"), inject_summary = false
"unexpected error: {err}"
); [feature.memory.extraction]
enabled = true
threshold = 42000
worker_max_turns = 2
[feature.memory.consolidation]
request_enabled = false
"#,
)
.unwrap();
let memory = cfg.feature.memory.unwrap();
assert_eq!(memory.enabled, Some(true));
assert_eq!(memory.staging_tools, Some(false));
assert_eq!(memory.resident.unwrap().inject_summary, Some(false));
assert_eq!(memory.consolidation.unwrap().request_enabled, Some(false));
let extraction = memory.extraction.unwrap();
assert_eq!(extraction.enabled, Some(true));
assert_eq!(extraction.threshold, Some(42_000));
assert_eq!(extraction.worker_max_turns, Some(2));
} }
#[test] #[test]
fn from_toml_accepts_extract_worker_max_turns() { fn from_toml_rejects_legacy_top_level_memory_authority() {
let cfg = WorkerManifestConfig::from_toml( let err = WorkerManifestConfig::from_toml(
r#" r#"
[memory] [memory]
extract_worker_max_turns = 2 extract_worker_max_turns = 2
"#, "#,
) )
.unwrap(); .unwrap_err();
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2)); assert!(
err.to_string().contains("memory"),
"unexpected error: {err}"
);
} }
#[test] #[test]
@@ -1948,7 +2031,7 @@ worker_max_turns = 7
fn feature_flags_default_disabled_in_resolved_manifest() { fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled); assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled); assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
@@ -2002,6 +2085,7 @@ enabled = false
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -2025,8 +2109,8 @@ enabled = false
} }
); );
assert!(!manifest.feature.orchestration.enabled); assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.memory.staging); assert!(!manifest.feature.memory.profile.staging_tools);
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
} }
@@ -2074,7 +2158,7 @@ readiness_check = true
enabled = true enabled = true
[feature.memory] [feature.memory]
staging = true staging_tools = true
[feature.manage_workdir] [feature.manage_workdir]
enabled = true enabled = true
@@ -2104,6 +2188,7 @@ enabled = true
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -2111,8 +2196,8 @@ enabled = true
}) })
.try_into() .try_into()
.unwrap(); .unwrap();
assert!(manifest.feature.memory.enabled); assert!(manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.staging); assert!(manifest.feature.memory.profile.staging_tools);
assert!(manifest.feature.manage_workdir.enabled); assert!(manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.ticket.enabled); assert!(manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket.authoring); assert!(!manifest.feature.ticket.authoring);
@@ -2180,6 +2265,7 @@ permission = "write"
target: abs("/worker"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
+1 -1
View File
@@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5; pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited. /// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
/// See [`crate::MemoryConfig::extract_worker_max_turns`]. /// See [`crate::MemoryExtractionProfileConfig::worker_max_turns`].
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8); pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
+666 -177
View File
@@ -29,7 +29,7 @@ pub use profile::{
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value, WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
validate_profile_execution_target, validate_profile_execution_target,
}; };
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule, SymlinkPolicy};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
@@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize};
/// part of the manifest — it is the process's `std::env::current_dir()` /// part of the manifest — it is the process's `std::env::current_dir()`
/// at construction time. /// at construction time.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerManifest { pub struct WorkerManifest {
pub worker: WorkerMeta, pub worker: WorkerMeta,
pub model: ModelManifest, pub model: ModelManifest,
@@ -68,10 +69,6 @@ pub struct WorkerManifest {
/// resolve disabled so Profile authors choose the exposed built-in surfaces. /// resolve disabled so Profile authors choose the exposed built-in surfaces.
#[serde(default)] #[serde(default)]
pub feature: FeatureConfig, pub feature: FeatureConfig,
/// Explicit plugin package enablement. Discovery remains read-only; only
/// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)]
pub plugins: plugin::PluginConfig,
/// Explicit external Model Context Protocol provider configuration. This /// Explicit external Model Context Protocol provider configuration. This
/// is config data only: declaring a server never starts a subprocess or /// is config data only: declaring a server never starts a subprocess or
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate /// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
@@ -80,11 +77,6 @@ pub struct WorkerManifest {
pub mcp: McpConfig, pub mcp: McpConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfig>, pub compaction: Option<CompactionConfig>,
/// Memory subsystem configuration. Presence of `[memory]` configures memory
/// storage, extraction, consolidation, and resident injection, but memory
/// tools are surfaced only when `[feature.memory].enabled = true`.
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// First-class web tools configuration. Network access remains fail-closed /// First-class web tools configuration. Network access remains fail-closed
/// under this config; WebSearch/WebFetch schemas are surfaced only when /// under this config; WebSearch/WebFetch schemas are surfaced only when
/// `[feature.web].enabled = true`. /// `[feature.web].enabled = true`.
@@ -109,12 +101,13 @@ pub struct WorkerManifest {
/// profile/config data only: they do not carry runtime Worker names, sockets, /// profile/config data only: they do not carry runtime Worker names, sockets,
/// sessions, secrets, or resolved host state. Tool registration still applies /// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks. /// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FeatureConfig { pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub task: FeatureFlagConfig, pub task: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub memory: MemoryFeatureConfig, pub memory: ResolvedMemoryFeatureConfig,
#[serde(default)] #[serde(default)]
pub web: FeatureFlagConfig, pub web: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
@@ -139,15 +132,13 @@ pub struct FeatureConfig {
pub merge_request: MergeRequestFeatureConfig, pub merge_request: MergeRequestFeatureConfig,
#[serde(default)] #[serde(default)]
pub orchestration: FeatureFlagConfig, pub orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
} }
impl Default for FeatureConfig { impl Default for FeatureConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
task: FeatureFlagConfig::disabled(), task: FeatureFlagConfig::disabled(),
memory: MemoryFeatureConfig::disabled(), memory: ResolvedMemoryFeatureConfig::default(),
web: FeatureFlagConfig::disabled(), web: FeatureFlagConfig::disabled(),
image: FeatureFlagConfig::disabled(), image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(), sub_worker: FeatureFlagConfig::disabled(),
@@ -159,7 +150,6 @@ impl Default for FeatureConfig {
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(), orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
} }
} }
} }
@@ -222,34 +212,139 @@ const fn default_true() -> bool {
true true
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryFeatureConfig { #[serde(default, deny_unknown_fields)]
#[serde(default)] pub struct MemoryFeatureProfileConfig {
pub enabled: bool, pub enabled: bool,
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools. /// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
#[serde(default)] pub staging_tools: bool,
pub staging: bool, pub resident: MemoryResidentProfileConfig,
pub extraction: MemoryExtractionProfileConfig,
pub consolidation: MemoryConsolidationProfileConfig,
} }
impl MemoryFeatureConfig { impl MemoryFeatureProfileConfig {
pub const fn disabled() -> Self { pub fn disabled() -> Self {
Self { Self::default()
enabled: false,
staging: false,
}
} }
pub const fn enabled() -> Self { pub fn enabled() -> Self {
Self { Self {
enabled: true, enabled: true,
staging: false, ..Self::default()
} }
} }
} }
impl Default for MemoryFeatureConfig { impl Default for MemoryFeatureProfileConfig {
fn default() -> Self { fn default() -> Self {
Self::disabled() Self {
enabled: false,
staging_tools: false,
resident: MemoryResidentProfileConfig::default(),
extraction: MemoryExtractionProfileConfig::default(),
consolidation: MemoryConsolidationProfileConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryResidentProfileConfig {
pub inject_summary: bool,
}
impl Default for MemoryResidentProfileConfig {
fn default() -> Self {
Self {
inject_summary: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryExtractionProfileConfig {
pub enabled: bool,
pub model: Option<ModelManifest>,
pub threshold: Option<u64>,
pub worker_max_turns: Option<u32>,
}
impl Default for MemoryExtractionProfileConfig {
fn default() -> Self {
Self {
enabled: true,
model: None,
threshold: Some(50_000),
worker_max_turns: defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryConsolidationProfileConfig {
pub request_enabled: bool,
}
impl Default for MemoryConsolidationProfileConfig {
fn default() -> Self {
Self {
request_enabled: true,
}
}
}
/// Immutable Memory execution configuration persisted in a resolved Worker Manifest.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default, deny_unknown_fields)]
pub struct ResolvedMemoryFeatureConfig {
pub profile: MemoryFeatureProfileConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
}
impl ResolvedMemoryFeatureConfig {
pub fn enabled(&self) -> bool {
self.profile.enabled
}
pub fn bind_workspace_settings(
&mut self,
settings: WorkspaceMemorySettingsSnapshot,
) -> Result<(), &'static str> {
if !self.profile.enabled {
if self.workspace_settings.is_some() {
return Err("disabled Memory feature must not carry Workspace settings");
}
return Ok(());
}
if self.workspace_settings.is_some() {
return Err("memory Workspace settings are already bound");
}
self.workspace_settings = Some(settings);
Ok(())
}
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
self.workspace_settings.clone()
}
pub fn validate_execution(&self) -> Result<(), &'static str> {
if self.profile.enabled && self.workspace_settings.is_none() {
return Err("enabled Memory feature requires trusted Workspace settings");
}
if !self.profile.enabled && self.workspace_settings.is_some() {
return Err("disabled Memory feature must not carry Workspace settings");
}
if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0
|| !is_normalized_workspace_memory_language(&settings.language))
{
return Err("Memory Workspace settings snapshot metadata is invalid");
}
Ok(())
} }
} }
@@ -484,98 +579,6 @@ pub struct WorkspaceMemorySettingsSnapshot {
pub language: String, pub language: String,
} }
/// Memory subsystem configuration. Presence in the manifest enables
/// memory; `workspace_root` pins the memory workspace explicitly. When it
/// is absent, memory resolution searches upward from the Worker's pwd for a
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
/// as a memory root.
///
/// All fields are `Option`; defaults are applied at the consumer
/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple
/// (`upper.x.or(self.x)`) without a separate partial/resolved split.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryConfig {
/// Override for the memory workspace root. When `None`, consumers resolve
/// the root from their default path and ancestor `.yoi/memory` markers.
/// When set, must be an absolute path.
#[serde(default)]
pub workspace_root: Option<PathBuf>,
/// Maximum number of records returned by `MemoryQuery` /
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
#[serde(default)]
pub query_result_limit: Option<usize>,
/// Lines of context before and after each match in query excerpts.
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
#[serde(default)]
pub query_excerpt_lines: Option<usize>,
/// Whether the body of `memory/summary.md` is exposed in the resident
/// system-prompt section. `None` ⇒ enabled.
#[serde(default)]
pub inject_summary: Option<bool>,
/// Workspace that owns the bound Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
/// Monotonic revision of the bound Workspace Memory settings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_revision: Option<u64>,
/// Language from the bound Workspace Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Optional model for the extract worker. When `None`,
/// the main engine model is cloned via `clone_boxed()`. Lightweight
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
/// recommended.
#[serde(default)]
pub extract_model: Option<ModelManifest>,
/// Cumulative input-token threshold (since the last extract pointer)
/// that triggers an extract run. `None` disables the extract trigger
/// entirely; memory tools and resident injection still work, only
/// the auto-extract trigger is dormant.
#[serde(default)]
pub extract_threshold: Option<u64>,
/// Optional maximum extract-worker tool-loop depth. `None` leaves
/// the worker unlimited; the default bounds runaway short-context
/// loops. Falls through to
/// [`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`] when unset.
#[serde(default)]
pub extract_worker_max_turns: Option<u32>,
/// Optional model for the consolidation worker. When
/// `None`, the main engine model is cloned via `clone_boxed()`.
/// Reasoning-class models are recommended.
#[serde(default)]
pub consolidation_model: Option<ModelManifest>,
/// Consolidation trigger: file-count threshold of `_staging/`. The
/// consolidation run fires when the staging directory has at least
/// this many entries. Either threshold reaching its limit fires
/// consolidation (logical OR). `None` for both thresholds ⇒
/// consolidation disabled.
#[serde(default)]
pub consolidation_threshold_files: Option<usize>,
/// Consolidation trigger: byte-size threshold across all `_staging/`
/// entries. Either threshold reaching its limit fires consolidation.
/// `None` for both thresholds ⇒ consolidation disabled.
#[serde(default)]
pub consolidation_threshold_bytes: Option<u64>,
}
impl MemoryConfig {
/// Replace any untrusted manifest values with a trusted Workspace snapshot.
pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) {
self.workspace_id = Some(snapshot.workspace_id.clone());
self.settings_revision = Some(snapshot.settings_revision);
self.language = Some(snapshot.language.clone());
}
/// Return the complete bound Workspace settings snapshot, if every field is present.
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
Some(WorkspaceMemorySettingsSnapshot {
workspace_id: self.workspace_id.clone()?,
settings_revision: self.settings_revision?,
language: self.language.clone()?,
})
}
}
/// Worker metadata. /// Worker metadata.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerMeta { pub struct WorkerMeta {
@@ -931,6 +934,10 @@ impl Default for CompactionConfig {
} }
impl WorkerManifest { impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
}
/// Parse a manifest from a TOML string. /// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> { pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?; config::reject_removed_manifest_fields(s)?;
@@ -941,6 +948,267 @@ impl WorkerManifest {
} }
} }
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LegacyMemoryFeatureConfig {
enabled: bool,
staging: bool,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LegacyMemoryConfig {
#[serde(rename = "workspace_root")]
_workspace_root: Option<PathBuf>,
#[serde(rename = "query_result_limit")]
_query_result_limit: Option<usize>,
#[serde(rename = "query_excerpt_lines")]
_query_excerpt_lines: Option<usize>,
inject_summary: Option<bool>,
workspace_id: Option<String>,
settings_revision: Option<u64>,
language: Option<String>,
extract_model: Option<ModelManifest>,
extract_threshold: Option<u64>,
extract_worker_max_turns: Option<u32>,
consolidation_model: Option<ModelManifest>,
consolidation_threshold_files: Option<usize>,
consolidation_threshold_bytes: Option<u64>,
}
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
pub fn write_persisted_worker_manifest_snapshot(
manifest: &WorkerManifest,
) -> Result<serde_json::Value, serde_json::Error> {
Ok(serde_json::json!({
"schema_version": RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION,
"manifest": serde_json::to_value(manifest)?,
}))
}
/// Read a durable resolved Worker Manifest through the versioned compatibility
/// boundary. Runtime code must not deserialize persisted snapshots directly.
pub fn read_persisted_worker_manifest_snapshot(
snapshot: serde_json::Value,
) -> Result<WorkerManifest, serde_json::Error> {
let object = snapshot.as_object().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot must be an object",
))
})?;
if let Some(version) = object.get("schema_version") {
let version = version.as_u64().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot schema_version must be an integer",
))
})?;
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
{
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported resolved Worker manifest snapshot schema version {version}"),
)));
}
if object.len() != 2 {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot contains unknown fields",
)));
}
let mut manifest = object.get("manifest").cloned().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot is missing manifest",
))
})?;
if manifest
.as_object()
.is_some_and(|manifest| manifest.contains_key("memory"))
{
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"current resolved Worker manifest contains removed top-level memory authority",
)));
}
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
migrate_legacy_manifest_authority(&mut manifest)?;
}
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
}
migrate_legacy_resolved_manifest_snapshot(snapshot)
}
fn validate_persisted_worker_manifest(
manifest: WorkerManifest,
) -> Result<WorkerManifest, serde_json::Error> {
manifest
.feature
.memory
.validate_execution()
.map_err(|message| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
message,
))
})?;
Ok(manifest)
}
fn migrate_legacy_manifest_authority(
manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> {
let root = manifest.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest must be an object",
))
})?;
root.remove("plugins");
if let Some(feature) = root.get_mut("feature") {
let feature = feature.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest feature must be an object",
))
})?;
feature.remove("plugins");
feature.remove("ticket_orchestration");
if let Some(workers) = feature.remove("workers") {
feature
.entry("sub_worker".to_string())
.or_insert_with(|| workers.clone());
feature.entry("worker".to_string()).or_insert(workers);
}
if let Some(ticket) = feature
.get_mut("ticket")
.and_then(serde_json::Value::as_object_mut)
&& let Some(access) = ticket.remove("access")
&& ticket
.get("enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
&& access.as_str() == Some("lifecycle")
{
ticket.insert("authoring".to_string(), serde_json::Value::Bool(true));
ticket.insert("thread".to_string(), serde_json::Value::Bool(true));
ticket.insert("workflow".to_string(), serde_json::Value::Bool(true));
}
}
Ok(())
}
fn migrate_legacy_resolved_manifest_snapshot(
mut snapshot: serde_json::Value,
) -> Result<WorkerManifest, serde_json::Error> {
let root = snapshot.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest snapshot must be an object",
))
})?;
let legacy_memory = root.remove("memory");
let feature = root
.entry("feature")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest feature must be an object",
))
})?;
let legacy_feature_memory: LegacyMemoryFeatureConfig = serde_json::from_value(
feature
.remove("memory")
.unwrap_or_else(|| serde_json::json!({})),
)?;
let requested_enabled = legacy_feature_memory.enabled;
let staging_tools = legacy_feature_memory.staging;
let legacy_memory: LegacyMemoryConfig =
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
let mut workspace_settings = match (
legacy_memory.workspace_id,
legacy_memory.settings_revision,
legacy_memory.language,
) {
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
"workspace_id": workspace_id,
"settings_revision": settings_revision,
"language": language,
})),
(None, None, None) => None,
_ => {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest contains a partial Memory settings snapshot",
)));
}
};
if !requested_enabled {
workspace_settings = None;
}
// Legacy standalone manifests could enable process-local Memory without a
// Workspace-owned settings snapshot. That authority no longer exists, so
// migration safely disables Memory instead of treating the whole Worker
// snapshot as corrupt.
let enabled = requested_enabled && workspace_settings.is_some();
let extraction_enabled = legacy_memory.extract_threshold.is_some();
if legacy_memory.consolidation_model.is_some() {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest uses a Worker-owned consolidation model that cannot be migrated to Backend authority",
)));
}
let consolidation_enabled = match (
legacy_memory.consolidation_threshold_files,
legacy_memory.consolidation_threshold_bytes,
) {
(None, None) => false,
(Some(5), Some(50_000)) => true,
_ => {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest uses custom consolidation thresholds that cannot be migrated to Backend policy",
)));
}
};
let mut resolved = serde_json::json!({
"profile": {
"enabled": enabled,
"staging_tools": staging_tools,
"resident": {
"inject_summary": legacy_memory.inject_summary.unwrap_or(true),
},
"extraction": {
"enabled": extraction_enabled,
"model": serde_json::to_value(legacy_memory.extract_model)?,
"threshold": legacy_memory.extract_threshold,
"worker_max_turns": legacy_memory.extract_worker_max_turns,
},
"consolidation": {
"request_enabled": consolidation_enabled,
},
},
});
if let Some(workspace_settings) = workspace_settings {
resolved
.as_object_mut()
.expect("resolved Memory config is an object")
.insert("workspace_settings".to_string(), workspace_settings);
}
feature.insert("memory".to_string(), resolved);
migrate_legacy_manifest_authority(&mut snapshot)?;
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1101,33 +1369,61 @@ model_id = "claude-sonnet-4-20250514"
} }
#[test] #[test]
fn parse_plugin_enablement_config() { fn dynamic_plugin_manifest_config_is_rejected() {
let toml = format!( let toml = format!(
"{MINIMAL_REQUIRED}\n\ "{MINIMAL_REQUIRED}\n\
[[plugins.enabled]]\n\ [[plugins.enabled]]\n\
id = \"project:example\"\n\ id = \"project:example\"\n"
version = \"0.1.0\"\n\
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
surfaces = [\"hook\"]\n\n\
[plugins.enabled.config]\n\
greeting = \"hello\"\n"
); );
let manifest = WorkerManifest::from_toml(&toml).unwrap(); let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert_eq!(manifest.plugins.enabled.len(), 1); assert!(
let enabled = &manifest.plugins.enabled[0]; error
assert_eq!(enabled.id, "project:example"); .to_string()
assert_eq!( .contains("dynamic Plugins are not supported"),
enabled.version.as_ref().map(|version| version.0.as_str()), "unexpected error: {error}"
Some("0.1.0")
); );
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]); }
assert_eq!(
enabled #[test]
.config fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() {
.as_ref() let base =
.and_then(|value| value.get("greeting")) serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
.and_then(|value| value.as_str()),
Some("hello") let mut top_level = base.clone();
top_level.as_object_mut().unwrap().insert(
"plugins".to_string(),
serde_json::json!({
"resolved": [{
"package_path": "/tmp/ambient.yoi-plugin"
}]
}),
);
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
let mut nested = base;
nested
.get_mut("feature")
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"plugins".to_string(),
serde_json::json!({ "enabled": true }),
);
let error = serde_json::from_value::<WorkerManifest>(nested).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
}
#[test]
fn dynamic_plugin_feature_flag_is_rejected() {
let toml = format!("{MINIMAL_REQUIRED}\n[feature.plugins]\nenabled = true\n");
let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!(
error
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}"
); );
} }
@@ -1246,36 +1542,237 @@ model_id = "claude-sonnet-4-20250514"
} }
#[test] #[test]
fn omitted_memory_is_none() { fn omitted_memory_feature_is_disabled() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.memory.is_none()); assert!(!manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.workspace_settings.is_none());
} }
#[test] #[test]
fn empty_memory_section_enables_with_default_root() { fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n"); let toml = format!(
"{MINIMAL_REQUIRED}\n\
[feature.memory.profile]\n\
enabled = true\n\
staging_tools = false\n\n\
[feature.memory.profile.resident]\n\
inject_summary = false\n\n\
[feature.memory.profile.extraction]\n\
enabled = true\n\
threshold = 42000\n\
worker_max_turns = 2\n\n\
[feature.memory.workspace_settings]\n\
workspace_id = \"workspace-1\"\n\
settings_revision = 7\n\
language = \"日本語\"\n"
);
let manifest = WorkerManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.expect("memory section parsed"); assert!(manifest.feature.memory.profile.enabled);
assert!(mem.workspace_root.is_none()); assert!(!manifest.feature.memory.profile.resident.inject_summary);
assert_eq!(mem.inject_summary, None);
}
#[test]
fn memory_section_with_inject_summary_false() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!(mem.inject_summary, Some(false));
}
#[test]
fn memory_section_with_explicit_root() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!( assert_eq!(
mem.workspace_root.unwrap(), manifest.feature.memory.profile.extraction.threshold,
std::path::PathBuf::from("/some/where") Some(42_000)
);
assert_eq!(
manifest
.feature
.memory
.workspace_settings()
.unwrap()
.language,
"日本語"
);
}
#[test]
fn resolved_memory_execution_validation_fails_closed() {
let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
let mut enabled = ResolvedMemoryFeatureConfig::default();
enabled.profile.enabled = true;
assert!(enabled.validate_execution().is_err());
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
assert!(enabled.validate_execution().is_ok());
let mut disabled = ResolvedMemoryFeatureConfig::default();
disabled.workspace_settings = Some(snapshot.clone());
assert!(disabled.validate_execution().is_err());
assert!(disabled.bind_workspace_settings(snapshot).is_err());
}
#[test]
fn current_manifest_rejects_legacy_top_level_memory_authority() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
assert!(WorkerManifest::from_toml(&toml).is_err());
}
#[test]
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
let mut manifest =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
manifest["feature"]["memory"] = serde_json::json!({
"enabled": true,
"staging": true,
});
manifest["memory"] = serde_json::json!({
"workspace_root": "/discarded",
"query_result_limit": 999,
"inject_summary": false,
"workspace_id": "workspace-1",
"settings_revision": 9,
"language": "Français",
"extract_threshold": 1234,
"extract_worker_max_turns": 3,
"consolidation_threshold_files": 5,
"consolidation_threshold_bytes": 50000,
});
let migrated = read_persisted_worker_manifest_snapshot(manifest).unwrap();
assert!(migrated.feature.memory.profile.enabled);
assert!(migrated.feature.memory.profile.staging_tools);
assert!(!migrated.feature.memory.profile.resident.inject_summary);
assert_eq!(
migrated.feature.memory.profile.extraction.threshold,
Some(1234)
);
assert!(
migrated
.feature
.memory
.profile
.consolidation
.request_enabled
);
assert_eq!(
migrated
.feature
.memory
.workspace_settings()
.unwrap()
.language,
"Français"
);
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
assert_eq!(current["schema_version"], 3);
assert!(current["manifest"].get("memory").is_none());
let mut disabled =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
disabled["feature"]["memory"] = serde_json::json!({ "enabled": false });
disabled["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 9,
"language": "Français",
});
let disabled = read_persisted_worker_manifest_snapshot(disabled).unwrap();
assert!(!disabled.feature.memory.profile.enabled);
assert!(disabled.feature.memory.workspace_settings.is_none());
}
#[test]
fn persisted_manifest_adapter_drops_removed_plugin_authority() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let mut versioned = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
versioned["schema_version"] = serde_json::json!(2);
versioned["manifest"]["feature"]["plugins"] = serde_json::json!({ "enabled": true });
versioned["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("sub_worker");
versioned["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("worker");
versioned["manifest"]["feature"]["workers"] = serde_json::json!({ "enabled": true });
versioned["manifest"]["feature"]["ticket"] =
serde_json::json!({ "enabled": true, "access": "lifecycle" });
versioned["manifest"]["feature"]["ticket_orchestration"] =
serde_json::json!({ "enabled": false });
versioned["manifest"]["plugins"] = serde_json::json!({
"enabled": ["legacy-plugin"],
"config": { "legacy-plugin": { "legacy": true } }
});
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
assert_eq!(current["schema_version"], 3);
assert!(current["manifest"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("plugins").is_none());
assert!(current["manifest"]["feature"].get("workers").is_none());
assert_eq!(
current["manifest"]["feature"]["sub_worker"]["enabled"],
true
);
assert_eq!(current["manifest"]["feature"]["worker"]["enabled"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["authoring"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["thread"], true);
assert_eq!(current["manifest"]["feature"]["ticket"]["workflow"], true);
let mut legacy = serde_json::to_value(manifest).unwrap();
legacy.as_object_mut().unwrap().remove("memory");
legacy["feature"]["memory"] = serde_json::json!({
"enabled": true,
"staging": false
});
legacy["feature"]["plugins"] = serde_json::json!({ "enabled": false });
legacy["plugins"] = serde_json::json!({ "enabled": [] });
let legacy = read_persisted_worker_manifest_snapshot(legacy).unwrap();
let current = write_persisted_worker_manifest_snapshot(&legacy).unwrap();
assert_eq!(
current["manifest"]["feature"]["memory"]["profile"]["enabled"],
false
);
}
#[test]
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
let manifest =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
let mut mixed = manifest.clone();
mixed["feature"]["memory"] = serde_json::json!({ "enabled": true, "profile": {} });
mixed["memory"] = serde_json::json!({});
assert!(read_persisted_worker_manifest_snapshot(mixed).is_err());
let mut custom_policy =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
custom_policy["feature"]["memory"] = serde_json::json!({ "enabled": true });
custom_policy["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 1,
"language": "English",
"consolidation_threshold_files": 99,
"consolidation_threshold_bytes": 50000,
});
assert!(read_persisted_worker_manifest_snapshot(custom_policy).is_err());
let current = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let mut current = write_persisted_worker_manifest_snapshot(&current).unwrap();
current["manifest"]["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 1,
"language": "English",
});
assert!(read_persisted_worker_manifest_snapshot(current).is_err());
let mut missing_settings = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
missing_settings.feature.memory.profile.enabled = true;
let missing_settings = write_persisted_worker_manifest_snapshot(&missing_settings).unwrap();
assert!(read_persisted_worker_manifest_snapshot(missing_settings).is_err());
let mut malformed_legacy = manifest.clone();
malformed_legacy["feature"]["memory"] = serde_json::json!({ "enabled": "yes" });
malformed_legacy["memory"] = serde_json::json!({ "unknown": true });
assert!(read_persisted_worker_manifest_snapshot(malformed_legacy).is_err());
assert!(
read_persisted_worker_manifest_snapshot(serde_json::json!({
"schema_version": 4,
"manifest": manifest,
}))
.is_err()
); );
} }
@@ -1291,14 +1788,6 @@ model_id = "claude-sonnet-4-20250514"
)); ));
} }
#[test]
fn memory_section_with_language() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!(mem.language.as_deref(), Some("Japanese"));
}
#[test] #[test]
fn reject_unknown_scheme() { fn reject_unknown_scheme() {
let toml = let toml =
File diff suppressed because it is too large Load Diff
+56 -72
View File
@@ -18,11 +18,10 @@ use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
}; };
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{ use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError, EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
WorkerMetaConfig, paths, paths,
}; };
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
@@ -148,7 +147,6 @@ pub enum WorkspaceAuthorityRequirement {
MergeRequest, MergeRequest,
Objective, Objective,
Orchestration, Orchestration,
Plugins,
Ticket, Ticket,
Worker, Worker,
} }
@@ -162,7 +160,6 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
Self::MergeRequest => formatter.write_str("feature.merge_request"), Self::MergeRequest => formatter.write_str("feature.merge_request"),
Self::Objective => formatter.write_str("feature.objective"), Self::Objective => formatter.write_str("feature.objective"),
Self::Orchestration => formatter.write_str("feature.orchestration"), Self::Orchestration => formatter.write_str("feature.orchestration"),
Self::Plugins => formatter.write_str("feature.plugins or plugin packages"),
Self::Ticket => formatter.write_str("feature.ticket"), Self::Ticket => formatter.write_str("feature.ticket"),
Self::Worker => formatter.write_str("feature.worker"), Self::Worker => formatter.write_str("feature.worker"),
} }
@@ -185,7 +182,7 @@ pub fn validate_profile_execution_target(
if feature.manage_workdir.enabled { if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
} }
if feature.memory.enabled || feature.memory.staging { if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
requirements.insert(WorkspaceAuthorityRequirement::Memory); requirements.insert(WorkspaceAuthorityRequirement::Memory);
} }
if feature.merge_request.show if feature.merge_request.show
@@ -202,9 +199,6 @@ pub fn validate_profile_execution_target(
if feature.orchestration.enabled { if feature.orchestration.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Orchestration); requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
} }
if feature.plugins.enabled || !manifest.plugins.is_empty() {
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
}
if feature.ticket.enabled if feature.ticket.enabled
|| feature.ticket.authoring || feature.ticket.authoring
|| feature.ticket.thread || feature.ticket.thread
@@ -638,11 +632,9 @@ fn resolve_profile_value(
session: profile.session, session: profile.session,
permissions: profile.permissions, permissions: profile.permissions,
feature: profile.feature, feature: profile.feature,
plugins: profile.plugins,
mcp: profile.mcp, mcp: profile.mcp,
compaction, compaction,
web: profile.web, web: profile.web,
memory: profile.memory.map(Into::into),
skills: profile.skills, skills: profile.skills,
}; };
let config = let config =
@@ -663,51 +655,6 @@ fn resolve_profile_value(
}) })
} }
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileMemoryConfig {
#[serde(default)]
workspace_root: Option<PathBuf>,
#[serde(default)]
query_result_limit: Option<usize>,
#[serde(default)]
query_excerpt_lines: Option<usize>,
#[serde(default)]
inject_summary: Option<bool>,
#[serde(default)]
extract_model: Option<ModelManifest>,
#[serde(default)]
extract_threshold: Option<u64>,
#[serde(default)]
extract_worker_max_turns: Option<u32>,
#[serde(default)]
consolidation_model: Option<ModelManifest>,
#[serde(default)]
consolidation_threshold_files: Option<usize>,
#[serde(default)]
consolidation_threshold_bytes: Option<u64>,
}
impl From<ProfileMemoryConfig> for MemoryConfig {
fn from(profile: ProfileMemoryConfig) -> Self {
Self {
workspace_root: profile.workspace_root,
query_result_limit: profile.query_result_limit,
query_excerpt_lines: profile.query_excerpt_lines,
inject_summary: profile.inject_summary,
workspace_id: None,
settings_revision: None,
language: None,
extract_model: profile.extract_model,
extract_threshold: profile.extract_threshold,
extract_worker_max_turns: profile.extract_worker_max_turns,
consolidation_model: profile.consolidation_model,
consolidation_threshold_files: profile.consolidation_threshold_files,
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
}
}
}
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct ProfileConfig { struct ProfileConfig {
@@ -730,16 +677,12 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
feature: FeatureConfigPartial, feature: FeatureConfigPartial,
#[serde(default)] #[serde(default)]
plugins: PluginConfig,
#[serde(default)]
mcp: McpConfig, mcp: McpConfig,
#[serde(default)] #[serde(default)]
compaction: Option<serde_json::Value>, compaction: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
web: Option<WebConfig>, web: Option<WebConfig>,
#[serde(default)] #[serde(default)]
memory: Option<ProfileMemoryConfig>,
#[serde(default)]
skills: Option<SkillsConfig>, skills: Option<SkillsConfig>,
} }
@@ -940,12 +883,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
.map_err(|source| ProfileError::ProfileDeserialize { source })?; .map_err(|source| ProfileError::ProfileDeserialize { source })?;
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?; reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
} }
if let Some(memory) = &profile.memory
&& let Some(root) = &memory.workspace_root
&& root.is_absolute()
{
return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into()));
}
if let Some(skills) = &profile.skills { if let Some(skills) = &profile.skills {
for dir in &skills.directories { for dir in &skills.directories {
if dir.is_absolute() { if dir.is_absolute() {
@@ -1024,6 +961,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.join(path), target: workspace_base.join(path),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
} }
Ok(ScopeConfig { Ok(ScopeConfig {
@@ -1031,6 +969,7 @@ fn profile_scope_intent_to_config(
target: workspace_base.to_path_buf(), target: workspace_base.to_path_buf(),
permission, permission,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny, deny,
}) })
@@ -1299,7 +1238,9 @@ mod tests {
("settings_revision", serde_json::json!(2)), ("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")), ("language", serde_json::json!("Japanese")),
] { ] {
let artifact = serde_json::json!({ "memory": { (field): value } }); let artifact = serde_json::json!({
"feature": { "memory": { (field): value } }
});
let error = resolve_profile_artifact_value( let error = resolve_profile_artifact_value(
artifact, artifact,
ProfileSource::Registry { ProfileSource::Registry {
@@ -1319,6 +1260,51 @@ mod tests {
} }
} }
#[test]
fn ambient_plugin_directories_do_not_affect_builtin_profile_resolution() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace/nested");
std::fs::create_dir_all(&workspace).unwrap();
for root in [tmp.path(), tmp.path().join("workspace").as_path()] {
let package = root.join(".yoi/plugins/broken.yoi-plugin");
std::fs::create_dir_all(package.parent().unwrap()).unwrap();
std::fs::write(package, b"malformed ambient package").unwrap();
}
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve_for_target(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
ProfileResolveOptions::with_worker_name("standalone-worker"),
ProfileExecutionTarget::Standalone,
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "standalone-worker");
}
#[test]
fn profile_rejects_dynamic_plugin_configuration() {
let tmp = TempDir::new().unwrap();
for body in [
"[feature.plugins]\nenabled = true\n",
"[[plugins.enabled]]\nid = \"explicit:example\"\n",
] {
let profile = write_profile(tmp.path(), "plugin.toml", body);
let error = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
}
#[test] #[test]
fn builtin_default_resolves_as_a_standalone_local_capability_profile() { fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -1351,14 +1337,12 @@ mod tests {
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| { assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
rule.permission == protocol::Permission::Write && rule.target == tmp.path() rule.permission == protocol::Permission::Write && rule.target == tmp.path()
})); }));
assert!(!resolved.manifest.feature.memory.enabled); assert!(!resolved.manifest.feature.memory.profile.enabled);
assert!(!resolved.manifest.feature.ticket.enabled); assert!(!resolved.manifest.feature.ticket.enabled);
assert!(!resolved.manifest.feature.objective.enabled); assert!(!resolved.manifest.feature.objective.enabled);
assert!(!resolved.manifest.feature.flow.enabled); assert!(!resolved.manifest.feature.flow.enabled);
assert!(!resolved.manifest.feature.worker.enabled); assert!(!resolved.manifest.feature.worker.enabled);
assert!(!resolved.manifest.feature.manage_workdir.enabled); assert!(!resolved.manifest.feature.manage_workdir.enabled);
assert!(!resolved.manifest.feature.plugins.enabled);
assert!(resolved.manifest.plugins.is_empty());
} }
#[test] #[test]
@@ -1630,7 +1614,7 @@ enabled = false
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-worker"); assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert!(resolved.manifest.feature.task.enabled); assert!(resolved.manifest.feature.task.enabled);
assert!(!resolved.manifest.feature.memory.enabled); assert!(!resolved.manifest.feature.memory.profile.enabled);
assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled); assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.feature.ticket.enabled); assert!(resolved.manifest.feature.ticket.enabled);
+271 -69
View File
@@ -3,16 +3,17 @@
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every //! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path //! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`]. //! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are canonicalised (where //! All rule targets retain both their lexically normalized logical identity and
//! possible) so access checks are pure path comparisons. //! their provider-resolved identity. Allow rules select one identity explicitly;
//! deny rules always inspect both so aliases cannot bypass a restriction.
use std::ffi::OsString; use std::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use arc_swap::{ArcSwap, Guard}; use arc_swap::{ArcSwap, Guard};
use crate::{Permission, ScopeConfig, ScopeRule}; use crate::{Permission, ScopeConfig, ScopeRule, SymlinkPolicy};
/// Parsed, pwd-resolved set of allow/deny rules for a Worker. /// Parsed, pwd-resolved set of allow/deny rules for a Worker.
/// ///
@@ -26,10 +27,13 @@ pub struct Scope {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedRule { struct ResolvedRule {
/// Absolute, canonicalized-or-normalized target directory/file. /// Absolute, lexically normalized target as presented through the Workdir.
target: PathBuf, logical_target: PathBuf,
/// Absolute target after provider-side symbolic-link resolution.
resolved_target: PathBuf,
permission: Permission, permission: Permission,
recursive: bool, recursive: bool,
symlink_policy: SymlinkPolicy,
} }
/// Parsed filesystem authority this Worker may pass to spawned children. /// Parsed filesystem authority this Worker may pass to spawned children.
@@ -98,18 +102,46 @@ fn permission_denies_requested(denied: Permission, requested: Permission) -> boo
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool { fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_covers(available.permission, requested.permission) permission_covers(available.permission, requested.permission)
&& rule_path_set_contains(available, requested) && available.symlink_policy >= requested.symlink_policy
&& rule_path_set_contains(
available,
requested,
match available.symlink_policy {
SymlinkPolicy::Resolved => RuleIdentity::Resolved,
SymlinkPolicy::Logical => RuleIdentity::Logical,
},
)
} }
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool { fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
permission_denies_requested(deny.permission, requested.permission) permission_denies_requested(deny.permission, requested.permission)
&& rule_path_sets_overlap(deny, requested) && (rule_path_sets_overlap(deny, requested, RuleIdentity::Logical)
|| rule_path_sets_overlap(deny, requested, RuleIdentity::Resolved))
} }
fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) -> bool { #[derive(Clone, Copy)]
enum RuleIdentity {
Logical,
Resolved,
}
fn rule_target(rule: &ResolvedRule, identity: RuleIdentity) -> &Path {
match identity {
RuleIdentity::Logical => &rule.logical_target,
RuleIdentity::Resolved => &rule.resolved_target,
}
}
fn rule_path_set_contains(
available: &ResolvedRule,
requested: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let available_target = rule_target(available, identity);
let requested_target = rule_target(requested, identity);
match (available.recursive, requested.recursive) { match (available.recursive, requested.recursive) {
// A recursive grant contains every possible requested path below its target. // A recursive grant contains every possible requested path below its target.
(true, _) => requested.target.starts_with(&available.target), (true, _) => requested_target.starts_with(available_target),
// A non-recursive grant contains only the target and its direct children; // A non-recursive grant contains only the target and its direct children;
// a recursive request always includes descendants beyond that finite-depth // a recursive request always includes descendants beyond that finite-depth
// set. // set.
@@ -117,36 +149,42 @@ fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) ->
// Two non-recursive rules have the same finite-depth set only when their // Two non-recursive rules have the same finite-depth set only when their
// target is identical. A request rooted at a direct child would also grant // target is identical. A request rooted at a direct child would also grant
// that child's children, which are grandchildren of `available.target`. // that child's children, which are grandchildren of `available.target`.
(false, false) => requested.target == available.target, (false, false) => requested_target == available_target,
} }
} }
fn rule_path_sets_overlap(left: &ResolvedRule, right: &ResolvedRule) -> bool { fn rule_path_sets_overlap(
left: &ResolvedRule,
right: &ResolvedRule,
identity: RuleIdentity,
) -> bool {
let left_target = rule_target(left, identity);
let right_target = rule_target(right, identity);
match (left.recursive, right.recursive) { match (left.recursive, right.recursive) {
(true, true) => { (true, true) => {
left.target.starts_with(&right.target) || right.target.starts_with(&left.target) left_target.starts_with(right_target) || right_target.starts_with(left_target)
} }
(true, false) => recursive_and_non_recursive_sets_overlap(left, right), (true, false) => recursive_and_non_recursive_sets_overlap(left_target, right_target),
(false, true) => recursive_and_non_recursive_sets_overlap(right, left), (false, true) => recursive_and_non_recursive_sets_overlap(right_target, left_target),
(false, false) => { (false, false) => {
left.target == right.target left_target == right_target
|| direct_child(&left.target, &right.target) || direct_child(left_target, right_target)
|| direct_child(&right.target, &left.target) || direct_child(right_target, left_target)
} }
} }
} }
fn recursive_and_non_recursive_sets_overlap( fn recursive_and_non_recursive_sets_overlap(
recursive: &ResolvedRule, recursive_target: &Path,
non_recursive: &ResolvedRule, non_recursive_target: &Path,
) -> bool { ) -> bool {
// The non-recursive set is `{target} + direct children`. It overlaps a // The non-recursive set is `{target} + direct children`. It overlaps a
// recursive subtree when either the non-recursive target is inside that // recursive subtree when either the non-recursive target is inside that
// subtree, or the recursive subtree begins at the non-recursive target or // subtree, or the recursive subtree begins at the non-recursive target or
// one of its direct children. // one of its direct children.
non_recursive.target.starts_with(&recursive.target) non_recursive_target.starts_with(recursive_target)
|| recursive.target == non_recursive.target || recursive_target == non_recursive_target
|| direct_child(&recursive.target, &non_recursive.target) || direct_child(recursive_target, non_recursive_target)
} }
fn direct_child(child: &Path, parent: &Path) -> bool { fn direct_child(child: &Path, parent: &Path) -> bool {
@@ -201,23 +239,35 @@ impl Scope {
} }
/// Convenience constructor for tests and simple setups: a single /// Convenience constructor for tests and simple setups: a single
/// recursive `allow(Write)` rule rooted at `root`. /// recursive `allow(Write)` rule rooted at `root` with the default
/// resolved-target symlink policy.
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> { pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
let root = root.as_ref().canonicalize()?; let root = normalize_path(root.as_ref()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"scope root must be an absolute path without root traversal",
)
})?;
let resolved_root = resolve_path(&root)?;
Ok(Self { Ok(Self {
allow: vec![ResolvedRule { allow: vec![ResolvedRule {
target: root, logical_target: root,
resolved_target: resolved_root,
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}], }],
deny: Vec::new(), deny: Vec::new(),
}) })
} }
/// Resolve one rule target with the same symlink and missing-tail semantics /// Return one rule target in the identity selected by its symlink policy.
/// used by scope matching.
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> { pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
Ok(resolve_rule(rule)?.target) let rule = resolve_rule(rule)?;
Ok(match rule.symlink_policy {
SymlinkPolicy::Resolved => rule.resolved_target,
SymlinkPolicy::Logical => rule.logical_target,
})
} }
/// Return whether this effective scope fully contains a requested rule. /// Return whether this effective scope fully contains a requested rule.
@@ -244,10 +294,23 @@ impl Scope {
/// Returns `None` when `path` is outside every allow rule, or when /// Returns `None` when `path` is outside every allow rule, or when
/// deny rules have knocked it below `Read`. /// deny rules have knocked it below `Read`.
pub fn permission_at(&self, path: &Path) -> Option<Permission> { pub fn permission_at(&self, path: &Path) -> Option<Permission> {
let resolved = resolve_path(path)?; let logical = normalize_path(path)?;
let resolved = resolve_path(&logical).ok()?;
self.permission_at_paths(&logical, &resolved)
}
/// Effective permission for a path whose logical and provider-resolved
/// identities were obtained inside the filesystem provider boundary.
pub fn permission_at_paths(&self, logical: &Path, resolved: &Path) -> Option<Permission> {
let logical = normalize_path(logical)?;
let resolved = normalize_path(resolved)?;
let mut effective: Option<Permission> = None; let mut effective: Option<Permission> = None;
for rule in &self.allow { for rule in &self.allow {
if rule.matches(&resolved) { let candidate = match rule.symlink_policy {
SymlinkPolicy::Resolved => &resolved,
SymlinkPolicy::Logical => &logical,
};
if rule.matches(candidate, rule.symlink_policy) {
effective = match effective { effective = match effective {
None => Some(rule.permission), None => Some(rule.permission),
Some(cur) => Some(cur.max(rule.permission)), Some(cur) => Some(cur.max(rule.permission)),
@@ -256,11 +319,13 @@ impl Scope {
} }
let mut effective = effective?; let mut effective = effective?;
// Deny: min(min_deny) dictates the cap. Effective level is capped // Deny rules always inspect both identities. This prevents a logical
// strictly below that value, so deny(read) wipes access entirely. // alias or a second symlink to the same target from bypassing a deny.
let mut min_deny: Option<Permission> = None; let mut min_deny: Option<Permission> = None;
for rule in &self.deny { for rule in &self.deny {
if rule.matches(&resolved) { if rule.matches(&logical, SymlinkPolicy::Logical)
|| rule.matches(&resolved, SymlinkPolicy::Resolved)
{
min_deny = match min_deny { min_deny = match min_deny {
None => Some(rule.permission), None => Some(rule.permission),
Some(cur) => Some(cur.min(rule.permission)), Some(cur) => Some(cur.min(rule.permission)),
@@ -293,7 +358,7 @@ impl Scope {
/// rule, preserving declaration order. Does not account for deny /// rule, preserving declaration order. Does not account for deny
/// rules, which only cap effective permission at query time. /// rules, which only cap effective permission at query time.
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> { pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
self.allow.iter().map(|r| r.target.as_path()) self.allow.iter().map(|r| r.logical_target.as_path())
} }
/// Allow rules with their targets resolved to absolute paths. /// Allow rules with their targets resolved to absolute paths.
@@ -305,9 +370,10 @@ impl Scope {
self.allow self.allow
.iter() .iter()
.map(|r| ScopeRule { .map(|r| ScopeRule {
target: r.target.clone(), target: r.logical_target.clone(),
permission: r.permission, permission: r.permission,
recursive: r.recursive, recursive: r.recursive,
symlink_policy: r.symlink_policy,
}) })
.collect() .collect()
} }
@@ -322,9 +388,10 @@ impl Scope {
self.deny self.deny
.iter() .iter()
.map(|r| ScopeRule { .map(|r| ScopeRule {
target: r.target.clone(), target: r.logical_target.clone(),
permission: r.permission, permission: r.permission,
recursive: r.recursive, recursive: r.recursive,
symlink_policy: r.symlink_policy,
}) })
.collect() .collect()
} }
@@ -335,7 +402,7 @@ impl Scope {
self.allow self.allow
.iter() .iter()
.filter(|r| r.permission == Permission::Write) .filter(|r| r.permission == Permission::Write)
.map(|r| r.target.as_path()) .map(|r| r.logical_target.as_path())
} }
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended /// Build a new [`Scope`] equal to `self` with `extra_allow` appended
@@ -412,7 +479,10 @@ impl Scope {
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
fn push_rule(out: &mut String, rule: &ResolvedRule) { fn push_rule(out: &mut String, rule: &ResolvedRule) {
out.push_str(" - "); out.push_str(" - ");
out.push_str(&rule.target.display().to_string()); out.push_str(&rule.logical_target.display().to_string());
if rule.symlink_policy == SymlinkPolicy::Logical {
out.push_str(" [logical-symlinks]");
}
if !rule.recursive { if !rule.recursive {
out.push_str(" [non-recursive]"); out.push_str(" [non-recursive]");
} }
@@ -510,11 +580,15 @@ impl SharedScope {
} }
impl ResolvedRule { impl ResolvedRule {
fn matches(&self, path: &Path) -> bool { fn matches(&self, path: &Path, identity: SymlinkPolicy) -> bool {
let target = match identity {
SymlinkPolicy::Resolved => &self.resolved_target,
SymlinkPolicy::Logical => &self.logical_target,
};
if self.recursive { if self.recursive {
path.starts_with(&self.target) path.starts_with(target)
} else { } else {
path == self.target || path.parent() == Some(self.target.as_path()) path == target || path.parent() == Some(target.as_path())
} }
} }
} }
@@ -523,48 +597,84 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
if !rule.target.is_absolute() { if !rule.target.is_absolute() {
return Err(ScopeError::RelativeTarget(rule.target.clone())); return Err(ScopeError::RelativeTarget(rule.target.clone()));
} }
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget { let logical_target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
path: rule.target.clone(), path: rule.target.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"), source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
})?; })?;
let resolved_target =
resolve_path(&logical_target).map_err(|source| ScopeError::ResolveTarget {
path: rule.target.clone(),
source,
})?;
Ok(ResolvedRule { Ok(ResolvedRule {
target, logical_target,
resolved_target,
permission: rule.permission, permission: rule.permission,
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
}) })
} }
/// Convert `path` to an absolute form suitable for prefix comparison. /// Resolve every existing path component while retaining a missing final tail.
/// /// A dangling symlink is rejected rather than treated as an ordinary missing
/// Tries `canonicalize` on the full path first (resolves symlinks). If /// component because its resolved authority cannot be established.
/// the path doesn't exist yet, climbs to the closest existing ancestor, fn resolve_path(path: &Path) -> std::io::Result<PathBuf> {
/// canonicalizes it, then rejoins the missing tail. Returns `None` for let mut cursor = path;
/// relative inputs that have no existing ancestor to anchor against. let mut missing = Vec::<OsString>::new();
fn resolve_path(path: &Path) -> Option<PathBuf> { loop {
match std::fs::canonicalize(cursor) {
Ok(mut resolved) => {
for component in missing.iter().rev() {
resolved.push(component);
}
return normalize_path(&resolved).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"resolved target is not an absolute normalized path",
)
});
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(cursor)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Err(error);
}
let name = cursor.file_name().ok_or(error)?;
missing.push(name.to_os_string());
cursor = cursor.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"scope target has no existing ancestor",
)
})?;
}
Err(error) => return Err(error),
}
}
}
/// Normalize an absolute path for lexical scope comparison without consulting
/// filesystem metadata or resolving symbolic links.
fn normalize_path(path: &Path) -> Option<PathBuf> {
if !path.is_absolute() { if !path.is_absolute() {
return None; return None;
} }
if let Ok(canonical) = path.canonicalize() { let mut normalized = PathBuf::new();
return Some(canonical); for component in path.components() {
} match component {
let mut tail: Vec<OsString> = Vec::new(); Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
let mut cur = path.to_path_buf(); Component::RootDir => normalized.push(component.as_os_str()),
loop { Component::CurDir => {}
if let Ok(canonical) = cur.canonicalize() { Component::ParentDir => {
let mut out = canonical; if !normalized.pop() {
for segment in tail.iter().rev() { return None;
out.push(segment); }
} }
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)] #[cfg(test)]
@@ -577,6 +687,7 @@ mod tests {
target: target.to_path_buf(), target: target.to_path_buf(),
permission, permission,
recursive, recursive,
symlink_policy: Default::default(),
} }
} }
@@ -691,6 +802,7 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}; };
@@ -790,6 +902,7 @@ mod tests {
target: PathBuf::from("relative/path"), target: PathBuf::from("relative/path"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}; };
@@ -805,6 +918,88 @@ mod tests {
assert!(!scope.is_readable(&traversal)); assert!(!scope.is_readable(&traversal));
} }
#[cfg(unix)]
#[test]
fn scope_defaults_to_resolved_symlink_authority_and_logical_is_explicit() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
symlink(outside.path(), dir.path().join("external")).unwrap();
let resolved = Scope::writable(dir.path()).unwrap();
assert!(!resolved.is_readable(&dir.path().join("external/outside.txt")));
assert!(!resolved.is_writable(&dir.path().join("external/new.txt")));
let logical = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: Vec::new(),
})
.unwrap();
assert!(logical.is_readable(&dir.path().join("external/outside.txt")));
assert!(logical.is_writable(&dir.path().join("external/new.txt")));
assert!(!logical.is_readable(&outside.path().join("outside.txt")));
assert!(!logical.is_writable(&outside.path().join("new.txt")));
}
#[cfg(unix)]
#[test]
fn deny_rules_match_both_logical_alias_and_resolved_target() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let secret = root.path().join("secret");
std::fs::create_dir(&secret).unwrap();
std::fs::write(secret.join("key"), "hidden").unwrap();
symlink(&secret, root.path().join("alias")).unwrap();
let scope = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: vec![ScopeRule {
target: secret,
permission: Permission::Read,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
})
.unwrap();
assert!(!scope.is_readable(&root.path().join("alias/key")));
}
#[test]
fn delegation_symlink_policy_is_monotonically_attenuated() {
let root = TempDir::new().unwrap();
let mut parent_rule = allow_rule(root.path(), Permission::Write);
parent_rule.symlink_policy = SymlinkPolicy::Logical;
let logical_parent = DelegationScope::from_config(&ScopeConfig {
allow: vec![parent_rule],
deny: Vec::new(),
})
.unwrap();
let resolved_child = allow_rule(&root.path().join("child"), Permission::Read);
assert!(logical_parent.allows_rule(&resolved_child).unwrap());
let resolved_parent = DelegationScope::from_config(&ScopeConfig {
allow: vec![allow_rule(root.path(), Permission::Write)],
deny: Vec::new(),
})
.unwrap();
let mut logical_child = resolved_child;
logical_child.symlink_policy = SymlinkPolicy::Logical;
assert!(!resolved_parent.allows_rule(&logical_child).unwrap());
}
#[test] #[test]
fn summary_lists_readable_and_writable() { fn summary_lists_readable_and_writable() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -851,11 +1046,13 @@ mod tests {
target: docs.clone(), target: docs.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: false, recursive: false,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -914,6 +1111,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.unwrap(); .unwrap();
assert!(extended.is_readable(&extra.path().join("x"))); assert!(extended.is_readable(&extra.path().join("x")));
@@ -931,6 +1129,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
.unwrap(); .unwrap();
let f = sub.join("a.txt"); let f = sub.join("a.txt");
@@ -950,6 +1149,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
let base = Scope::writable(dir.path()) let base = Scope::writable(dir.path())
.unwrap() .unwrap()
@@ -1003,6 +1203,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1021,6 +1222,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
+13 -5
View File
@@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidateStagingOperation { pub struct MemoryConsolidateStagingOperation {
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_files: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_bytes: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -450,10 +447,21 @@ mod tests {
use super::*; use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate}; use crate::extract::{CandidateKind, ExtractedCandidate};
#[test]
fn consolidation_operation_rejects_caller_owned_thresholds() {
let error =
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
"force": false,
"threshold_files": 1,
}))
.unwrap_err();
assert!(error.to_string().contains("threshold_files"));
}
#[test] #[test]
fn staging_list_read_close_records_reason_and_deletes_candidate() { fn staging_list_read_close_records_reason_and_deletes_candidate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path()); let layout = WorkspaceLayout::resolve(temp.path());
let source = SourceRef { let source = SourceRef {
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
range: [0, 1], range: [0, 1],
+1 -2
View File
@@ -21,8 +21,7 @@ pub struct StagingEntry {
pub id: Uuid, pub id: Uuid,
pub path: PathBuf, pub path: PathBuf,
pub record: StagingRecord, pub record: StagingRecord,
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`) /// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
/// に使う。
pub bytes: u64, pub bytes: u64,
} }
+1
View File
@@ -23,6 +23,7 @@ fn deny_write(target: &Path) -> ScopeRule {
target: target.to_path_buf(), target: target.to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
} }
} }
+8 -33
View File
@@ -70,24 +70,12 @@ impl WorkspaceLayout {
Self { root: root.into() } Self { root: root.into() }
} }
/// Resolve a layout from a `MemoryConfig`. /// Resolve a layout from the nearest Memory marker.
/// ///
/// An explicit `memory.workspace_root` is honored exactly. Without an /// Resolution searches `default_root` and its ancestors for the nearest
/// explicit root, resolution searches `default_root` and its ancestors for /// `.yoi/memory` directory. This legacy local-storage helper owns its path
/// the nearest `.yoi/memory` directory. This keeps child worktrees that /// policy directly; resolved Worker Manifests do not carry storage paths.
/// contain `.yoi` project records such as tickets from pub fn resolve(default_root: &Path) -> Self {
/// becoming independent memory roots merely because they contain `.yoi`.
///
/// If no memory marker exists, this falls back to `default_root` because
/// existing call sites require a concrete layout. That fallback is a
/// no-marker compatibility path, not a `.yoi` marker interpretation; it
/// must not be used as evidence that `.yoi` alone enables repo-local
/// memory.
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
if let Some(root) = &cfg.workspace_root {
return Self::new(root.clone());
}
let root = let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf()); find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root) Self::new(root)
@@ -335,16 +323,6 @@ mod tests {
assert!(matches!(err, LintError::InvalidPath(_))); assert!(matches!(err, LintError::InvalidPath(_)));
} }
#[test]
fn resolve_uses_workspace_root_when_set() {
let cfg = manifest::MemoryConfig {
workspace_root: Some(PathBuf::from("/explicit")),
..Default::default()
};
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
assert_eq!(layout.root(), Path::new("/explicit"));
}
#[test] #[test]
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() { fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -353,8 +331,7 @@ mod tests {
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap(); std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(&child).unwrap(); std::fs::create_dir_all(&child).unwrap();
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), workspace.as_path()); assert_eq!(layout.root(), workspace.as_path());
} }
@@ -366,8 +343,7 @@ mod tests {
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap(); std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap(); std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), workspace.as_path()); assert_eq!(layout.root(), workspace.as_path());
} }
@@ -381,8 +357,7 @@ mod tests {
assert_eq!(find_memory_marker_root(&child), None); assert_eq!(find_memory_marker_root(&child), None);
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), child.as_path()); assert_eq!(layout.root(), child.as_path());
} }
} }
+11 -239
View File
@@ -9,7 +9,6 @@ use thiserror::Error;
use uuid::Uuid; use uuid::Uuid;
const SCHEMA_VERSION: i64 = 12; const SCHEMA_VERSION: i64 = 12;
const PREVIOUS_SCHEMA_VERSION: i64 = 11;
const MAX_BODY_BYTES: usize = 16 * 1024; const MAX_BODY_BYTES: usize = 16 * 1024;
const DOMAIN_TABLES: [&str; 5] = [ const DOMAIN_TABLES: [&str; 5] = [
"merge_requests", "merge_requests",
@@ -37,7 +36,7 @@ impl MergeRequestState {
fn parse(v: &str) -> Result<Self, MergeRequestError> { fn parse(v: &str) -> Result<Self, MergeRequestError> {
match v { match v {
"draft" | "open" => Ok(Self::Open), "open" => Ok(Self::Open),
"merged" => Ok(Self::Merged), "merged" => Ok(Self::Merged),
"closed" => Ok(Self::Closed), "closed" => Ok(Self::Closed),
_ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))), _ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))),
@@ -1355,14 +1354,9 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
match schema_state(c)? { match schema_state(c)? {
SchemaState::Fresh => fresh(c), SchemaState::Fresh => fresh(c),
SchemaState::Current(SCHEMA_VERSION) => verify(c), SchemaState::Current(SCHEMA_VERSION) => verify(c),
SchemaState::Current(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Current),
SchemaState::Legacy(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Legacy),
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!( SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
"unsupported schema {v}" "unsupported schema {v}"
))), ))),
SchemaState::Legacy(v) => Err(MergeRequestError::Operation(format!(
"unsupported legacy schema {v}"
))),
} }
} }
@@ -1370,26 +1364,14 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
enum SchemaState { enum SchemaState {
Fresh, Fresh,
Current(i64), Current(i64),
Legacy(i64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreviousSchemaMarker {
Current,
Legacy,
} }
fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> { fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
let (current, legacy): (bool, bool) = c.query_row( let current: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema'),EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')", "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",
[], [],
|r| Ok((r.get(0)?, r.get(1)?)), |r| r.get(0),
)?; )?;
if current && legacy {
return Err(MergeRequestError::Corrupt(
"both current and legacy schema markers exist".into(),
));
}
if current { if current {
let (count, singleton, version): (i64, Option<i64>, Option<i64>) = c.query_row( let (count, singleton, version): (i64, Option<i64>, Option<i64>) = c.query_row(
"SELECT COUNT(*),MIN(singleton),MAX(version) FROM merge_request_schema", "SELECT COUNT(*),MIN(singleton),MAX(version) FROM merge_request_schema",
@@ -1406,22 +1388,6 @@ fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
})?; })?;
return Ok(SchemaState::Current(version)); return Ok(SchemaState::Current(version));
} }
if legacy {
let (count, version): (i64, Option<i64>) = c.query_row(
"SELECT COUNT(*),MAX(version) FROM merge_request_schema_migrations",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if count != 1 {
return Err(MergeRequestError::Corrupt(
"legacy schema marker must contain exactly one version".into(),
));
}
let version = version.ok_or_else(|| {
MergeRequestError::Corrupt("legacy schema marker version is null".into())
})?;
return Ok(SchemaState::Legacy(version));
}
let domain_tables: bool = c.query_row( let domain_tables: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'merge_request*')", "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'merge_request*')",
[], [],
@@ -1436,214 +1402,20 @@ fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
} }
fn fresh(c: &Connection) -> Result<(), MergeRequestError> { fn fresh(c: &Connection) -> Result<(), MergeRequestError> {
let t = c.unchecked_transaction()?; let t = c.unchecked_transaction()?;
tables(&t, true)?; tables(&t)?;
t.execute("INSERT INTO merge_request_schema VALUES(1,12)", [])?; t.execute(
"INSERT INTO merge_request_schema VALUES(1,?1)",
params![SCHEMA_VERSION],
)?;
fk(&t)?; fk(&t)?;
t.commit()?; t.commit()?;
Ok(()) Ok(())
} }
fn tables(t: &Transaction<'_>, marker: bool) -> Result<(), MergeRequestError> { fn tables(t: &Transaction<'_>) -> Result<(), MergeRequestError> {
if marker { t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?;
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?
}
t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?; t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?;
Ok(()) Ok(())
} }
fn from_v11(
c: &Connection,
previous_marker: PreviousSchemaMarker,
) -> Result<(), MergeRequestError> {
let t = c.unchecked_transaction()?;
if previous_marker == PreviousSchemaMarker::Legacy {
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?;
t.execute(
"INSERT INTO merge_request_schema VALUES(1,?1)",
params![PREVIOUS_SCHEMA_VERSION],
)?;
}
t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11;ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11;ALTER TABLE merge_request_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?;
tables(&t, false)?;
t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?;
t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?;
migrate_events(&t)?;
if previous_marker == PreviousSchemaMarker::Legacy {
t.execute("DROP TABLE merge_request_schema_migrations", [])?;
}
t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?;
fk(&t)?;
t.commit()?;
Ok(())
}
fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> {
let attempts = {
let mut s=t.prepare("SELECT a.workspace_id,a.attempt_id,a.merge_request_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.status,a.created_at,a.consumed_at,r.head_commit FROM merge_request_review_attempts_v11 a JOIN merge_request_revisions_v11 r ON r.workspace_id=a.workspace_id AND r.merge_request_id=a.merge_request_id AND r.revision_id=a.revision_id ORDER BY a.created_at")?;
s.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, String>(6)?,
r.get::<_, String>(7)?,
r.get::<_, Option<String>>(8)?,
r.get::<_, String>(9)?,
))
})?
.collect::<Result<Vec<_>, _>>()?
};
for (ws, a, mr, pr, pw, child, status, created, consumed, subject) in attempts {
let req = ReviewRequestedEvent {
event_id: format!("migrated-request-{a}"),
sequence: next_seq(t, &ws, &mr)?,
subject_ref: subject.clone(),
requested_by: WorkerIdentity {
runtime_id: pr.clone(),
worker_id: pw,
},
reviewer: WorkerIdentity {
runtime_id: pr,
worker_id: child,
},
created_at: time(&created)?,
};
insert_event(t, &ws, &mr, "review_requested", &req, req.created_at, None)?;
if status == "submitted" {
let(row_dec,row_body,row_at):(String,String,String)=t.query_row("SELECT decision,body,submitted_at FROM merge_request_reviews_v11 WHERE workspace_id=?1 AND attempt_id=?2",params![ws,a],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
let findings = {
let mut s=t.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings_v11 WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal")?;
s.query_map(params![ws, a], |r| {
Ok(ReviewFinding {
severity: match r.get::<_, String>(0)?.as_str() {
"blocker" => FindingSeverity::Blocker,
"major" => FindingSeverity::Major,
"minor" => FindingSeverity::Minor,
_ => FindingSeverity::Note,
},
code: r.get(1)?,
path: r.get(2)?,
line: r.get(3)?,
body: r.get(4)?,
})
})?
.collect::<Result<Vec<_>, _>>()?
};
let rev = ReviewEvent {
event_id: format!("migrated-review-{a}"),
sequence: next_seq(t, &ws, &mr)?,
request_event_id: req.event_id,
subject_ref: subject,
decision: if row_dec == "approve" {
ReviewDecision::Approve
} else {
ReviewDecision::RequestChanges
},
body: row_body,
findings,
reviewer: req.reviewer,
created_at: time(&row_at)?,
};
insert_event(t, &ws, &mr, "review", &rev, rev.created_at, None)?
} else {
let at = consumed.as_deref().unwrap_or(&created);
let e = ReviewCancelledEvent {
event_id: format!("migrated-cancel-{a}"),
sequence: next_seq(t, &ws, &mr)?,
request_event_id: req.event_id,
subject_ref: subject,
reason: format!(
"legacy `{status}` review request cancelled because its capability cannot be migrated"
),
created_at: time(at)?,
};
insert_event(t, &ws, &mr, "review_cancelled", &e, e.created_at, None)?
}
}
let completed = {
let mut q=t.prepare("SELECT c.workspace_id,c.operation_id,c.ticket_id,c.target_commit,c.source_commit,c.result_commit,c.strategy,c.resolution,c.completion_actor_runtime_id,c.completion_actor_worker_id,c.updated_at,rel.merge_request_id FROM merge_request_completion_operations_v11 c JOIN merge_request_ticket_relations_v11 rel ON rel.workspace_id=c.workspace_id AND rel.ticket_id=c.ticket_id WHERE c.status='completed' ORDER BY c.updated_at")?;
q.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, Option<String>>(3)?,
r.get::<_, Option<String>>(4)?,
r.get::<_, Option<String>>(5)?,
r.get::<_, Option<String>>(6)?,
r.get::<_, Option<String>>(7)?,
r.get::<_, Option<String>>(8)?,
r.get::<_, Option<String>>(9)?,
r.get::<_, String>(10)?,
r.get::<_, String>(11)?,
))
})?
.collect::<Result<Vec<_>, _>>()?
};
for (
ws,
op,
_ticket,
target,
source,
result,
strategy,
resolution,
runtime,
worker,
updated,
mr,
) in completed
{
let subject = source.ok_or_else(|| {
MergeRequestError::Operation(format!("completed operation {op} lacks source evidence"))
})?;
let approval:Option<String>=t.query_row("SELECT event_id FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND kind='review' AND json_extract(payload_json,'$.subject_ref')=?3 AND json_extract(payload_json,'$.decision')='approve' ORDER BY sequence DESC LIMIT 1",params![ws,mr,subject],|r|r.get(0)).optional()?;
let approval = approval.ok_or_else(|| {
MergeRequestError::Operation(format!(
"completed operation {op} lacks approval evidence"
))
})?;
let e = MergeEvent {
event_id: format!("migrated-merge-{op}"),
sequence: next_seq(t, &ws, &mr)?,
operation_id: op,
approval_event_id: approval,
approved_source_ref: subject,
target_ref_before: target.ok_or_else(|| {
MergeRequestError::Operation("completed operation lacks target evidence".into())
})?,
target_ref_after: result.ok_or_else(|| {
MergeRequestError::Operation("completed operation lacks result evidence".into())
})?,
strategy: if strategy.as_deref() == Some("merge") {
MergeStrategy::Merge
} else {
MergeStrategy::FastForward
},
resolution: match resolution.as_deref() {
Some("clean") => ConflictResolution::Clean,
Some("conflicts_resolved") => ConflictResolution::ConflictsResolved,
_ => ConflictResolution::None,
},
merged_by: WorkerIdentity {
runtime_id: runtime.unwrap_or_else(|| "legacy".into()),
worker_id: worker.unwrap_or_else(|| "legacy".into()),
},
created_at: time(&updated)?,
};
insert_event(
t,
&ws,
&mr,
"merge",
&e,
e.created_at,
Some(&e.operation_id),
)?;
}
Ok(())
}
fn verify(c: &Connection) -> Result<(), MergeRequestError> { fn verify(c: &Connection) -> Result<(), MergeRequestError> {
for n in DOMAIN_TABLES { for n in DOMAIN_TABLES {
let e: bool = c.query_row( let e: bool = c.query_row(
+15 -63
View File
@@ -301,21 +301,13 @@ fn review_revocation_invalidates_readiness() {
} }
#[test] #[test]
fn legacy_v11_migration_preserves_review_events_and_replaces_marker() { fn fresh_schema_uses_version_12_and_reopens_as_current() {
let c = Connection::open_in_memory().unwrap(); let c = Connection::open_in_memory().unwrap();
c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap();
c.execute_batch( c.execute_batch(
"CREATE TABLE unrelated_parent(left_id TEXT,right_id TEXT,PRIMARY KEY(left_id,right_id));CREATE TABLE unrelated_child(left_id TEXT REFERENCES unrelated_parent(left_id));", "CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
) )
.unwrap(); .unwrap();
let unrelated_mismatch = c
.query_row("PRAGMA foreign_key_check", [], |_| Ok(()))
.unwrap_err();
assert!(
unrelated_mismatch
.to_string()
.contains("foreign key mismatch")
);
merge_request::migrate(&c).unwrap(); merge_request::migrate(&c).unwrap();
assert_eq!( assert_eq!(
c.query_row("SELECT version FROM merge_request_schema", [], |r| { c.query_row("SELECT version FROM merge_request_schema", [], |r| {
@@ -324,66 +316,26 @@ fn legacy_v11_migration_preserves_review_events_and_replaces_marker() {
.unwrap(), .unwrap(),
12 12
); );
let legacy_marker: bool = c merge_request::migrate(&c).unwrap();
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!legacy_marker);
let selector: Option<String> = c
.query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0))
.unwrap();
assert!(selector.is_none());
let kinds: String = c
.query_row(
"SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
kinds,
"review_requested,review,review_requested,review_cancelled"
);
let old: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!old);
} }
#[test] #[test]
fn failed_legacy_v11_migration_rolls_back_marker_bridge() { fn current_schema_validation_rejects_missing_tables() {
let c = Connection::open_in_memory().unwrap(); let c = Connection::open_in_memory().unwrap();
c.execute_batch( c.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(merge_request_id TEXT);", "CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
) )
.unwrap(); .unwrap();
merge_request::migrate(&c).unwrap();
assert!(merge_request::migrate(&c).is_err()); c.execute_batch("DROP TABLE merge_request_review_grants;")
for table in ["merge_request_schema_migrations", "merge_requests"] {
let exists: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
[table],
|r| r.get(0),
)
.unwrap();
assert!(exists, "{table} was not rolled back");
}
let current_marker: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",
[],
|r| r.get(0),
)
.unwrap(); .unwrap();
assert!(!current_marker);
let error = merge_request::migrate(&c).unwrap_err();
assert!(matches!(
error,
MergeRequestError::Corrupt(message)
if message == "missing `merge_request_review_grants`"
));
} }
#[test] #[test]
+543 -108
View File
@@ -11,6 +11,11 @@ use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError}; pub use identity::{WorkerId, WorkerIdParseError};
/// Allocate an opaque idempotency key for one client Submit request.
pub fn new_submission_request_id() -> String {
uuid::Uuid::now_v7().to_string()
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -27,21 +32,204 @@ fn is_false(value: &bool) -> bool {
// Method (Client → Worker via Unix Socket) // Method (Client → Worker via Unix Socket)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Trusted Server → Runtime transport header carrying the authenticated
/// browser Account identity for one Worker protocol connection.
///
/// Runtime accepts this only after its normal HTTP authentication succeeds;
/// serialized [`Method`] payloads cannot set authenticated source identity.
pub const AUTHENTICATED_ACCOUNT_ID_HEADER: &str = "x-yoi-authenticated-account-id";
/// Trusted source identity attached by an authenticated transport boundary.
///
/// Public clients cannot select this value directly. Runtime/Backend adapters
/// stamp it before forwarding an accepted Submit or Notify to a Worker.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum AuthenticatedInputSource {
/// Assigned whenever a serialized tracked method crosses an untrusted
/// protocol boundary. Receivers must handle it exactly like public input.
UntrustedWire,
Account {
account_id: String,
},
Worker {
runtime_id: String,
worker_id: String,
},
SubWorker {
session_id: String,
},
Backend {
operation_id: String,
},
}
impl Default for AuthenticatedInputSource {
fn default() -> Self {
Self::UntrustedWire
}
}
impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
Self::UntrustedWire => "untrusted-wire".into(),
Self::Account { account_id } => format!("account:{account_id}"),
Self::Worker {
runtime_id,
worker_id,
} => format!("worker:{runtime_id}:{worker_id}"),
Self::SubWorker { session_id } => format!("sub_worker:{session_id}"),
Self::Backend { operation_id } => format!("backend:{operation_id}"),
}
}
}
/// Caller-owned identity for one state-changing Worker command.
///
/// A controller accepts command ids in strictly increasing order. Exact retries
/// of an accepted id must retain the same command kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerCommandEnvelope {
pub command_id: u64,
}
impl WorkerCommandEnvelope {
pub fn new(command_id: u64) -> Self {
Self { command_id }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerCommandKind {
Resume,
Cancel,
Pause,
Compact,
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerCommandDisposition {
Accepted,
StaleCommandId,
Conflict,
InvalidState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerCommandAcknowledgement {
pub command_id: u64,
pub command: WorkerCommandKind,
pub disposition: WorkerCommandDisposition,
/// The complete authoritative state observed after command admission.
pub state: WorkerStateSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "state", rename_all = "snake_case")]
pub enum WorkerState {
Idle,
Busy(WorkerBusyState),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "state", rename_all = "snake_case")]
pub enum WorkerBusyState {
Run(WorkerRunState),
Maintenance(WorkerMaintenanceState),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerRunState {
Running,
Pausing,
Paused,
Cancelling,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerMaintenanceState {
Compacting,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerStateSnapshot {
/// Highest lifecycle command id observed by this controller instance.
pub last_command_id: u64,
pub state: WorkerState,
}
impl WorkerStateSnapshot {
pub fn initial() -> Self {
Self {
last_command_id: 0,
state: WorkerState::Idle,
}
}
/// Compatibility projection for Runtime catalog lifecycle. This value is
/// never command-admission authority and cannot produce `Stopped`.
pub fn catalog_status(&self) -> WorkerStatus {
match self.state {
WorkerState::Idle => WorkerStatus::Idle,
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)) => WorkerStatus::Paused,
WorkerState::Busy(WorkerBusyState::Run(_))
| WorkerState::Busy(WorkerBusyState::Maintenance(_)) => WorkerStatus::Running,
}
}
}
impl From<WorkerStatus> for WorkerStateSnapshot {
fn from(status: WorkerStatus) -> Self {
let state = match status {
WorkerStatus::Idle | WorkerStatus::Stopped => WorkerState::Idle,
WorkerStatus::Running => {
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running))
}
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
};
Self {
last_command_id: 0,
state,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")] #[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum Method { pub enum Method {
Run { /// Durably accept typed input for immediate activation or the session FIFO.
///
/// `submission_request_id` is generated by the authenticated caller and is
/// used only for idempotent retry. Worker allocates the durable
/// `submission_id` returned by [`Event::SubmissionAccepted`].
Submit {
submission_request_id: String,
input: Vec<Segment>, input: Vec<Segment>,
}, },
/// Runtime-internal Run carrying an opaque correlation id that is committed /// Authenticated transport form of Submit. Trusted adapters replace
/// with the resulting UserInput entry. This variant is not serializable on /// public Submit before forwarding it to the Worker.
/// the public Client → Worker protocol.
#[serde(skip)]
#[cfg_attr(feature = "typescript", ts(skip))] #[cfg_attr(feature = "typescript", ts(skip))]
RunTracked { SubmitTracked {
submission_request_id: String,
input: Vec<Segment>, input: Vec<Segment>,
submission_id: String, #[serde(skip_deserializing, default)]
source: AuthenticatedInputSource,
}, },
/// Human-readable text injected into the target Worker's LLM context /// Human-readable text injected into the target Worker's LLM context
/// as a non-blocking system message. `auto_run` controls whether an /// as a non-blocking system message. `auto_run` controls whether an
@@ -50,26 +238,63 @@ pub enum Method {
/// No side effects beyond LLM context; use `WorkerEvent` for typed /// No side effects beyond LLM context; use `WorkerEvent` for typed
/// lifecycle reports. /// lifecycle reports.
Notify { Notify {
notification_request_id: String,
message: String, message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")] #[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool, auto_run: bool,
}, },
/// Authenticated transport form of Notify.
#[cfg_attr(feature = "typescript", ts(skip))]
NotifyTracked {
notification_request_id: String,
message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool,
#[serde(skip_deserializing, default)]
source: AuthenticatedInputSource,
},
/// Typed lifecycle report from a child Worker to its direct parent. /// Typed lifecycle report from a child Worker to its direct parent.
WorkerEvent(WorkerEvent), WorkerEvent(WorkerEvent),
Resume, /// Return the authoritative FIFO summary without exposing queued payloads.
Cancel, ListPendingSubmissions,
/// Remove one queued submission. Running or already activated submissions
/// are immutable and therefore cannot be cancelled here.
CancelPendingSubmission {
submission_id: String,
expected_revision: u64,
},
/// Remove every queued submission while preserving the active run.
ClearPendingSubmissions {
expected_revision: u64,
},
/// Activate the next queued submission while the Worker is idle. This is an
/// explicit recovery operation and never resumes a paused run implicitly.
ContinuePending {
expected_revision: u64,
expected_head_id: String,
},
Resume {
command: WorkerCommandEnvelope,
},
Cancel {
command: WorkerCommandEnvelope,
},
/// Stop the in-flight turn and transition to `Paused`. /// Stop the in-flight turn and transition to `Paused`.
/// ///
/// Unlike `Cancel` (which discards and returns to `Idle`), a paused /// Unlike `Cancel` (which discards and returns to `Idle`), a paused
/// Worker can resume the interrupted work via `Resume`, or start a /// Worker can resume the interrupted work via `Resume`, or accept a
/// fresh turn via `Run` (orphan `tool_use` items are closed with a /// fresh `Submit` (orphan `tool_use` items are closed with a
/// synthetic tool result before the new user message is appended). /// synthetic tool result before the new user message is appended).
Pause, Pause {
command: WorkerCommandEnvelope,
},
/// Request an explicit compaction while the Worker is otherwise idle. /// Request an explicit compaction while the Worker is otherwise idle.
/// ///
/// This is a typed control method: clients must not send `compact` as a /// This is a typed control method: clients must not send `compact` as a
/// `Method::Run` user message. /// `Method::Submit` user message.
Compact, Compact {
command: WorkerCommandEnvelope,
},
/// Ask the Worker to list valid rewind targets from its authoritative session log. /// Ask the Worker to list valid rewind targets from its authoritative session log.
ListRewindTargets, ListRewindTargets,
/// Truncate the current session back to the selected rewind target and /// Truncate the current session back to the selected rewind target and
@@ -78,7 +303,9 @@ pub enum Method {
target: RewindTargetId, target: RewindTargetId,
expected_head_entries: usize, expected_head_entries: usize,
}, },
Shutdown, Shutdown {
command: WorkerCommandEnvelope,
},
/// Request a list of completion candidates from the Worker. /// Request a list of completion candidates from the Worker.
/// ///
/// Reply is sent on the same socket as `Event::Completions` (not /// Reply is sent on the same socket as `Event::Completions` (not
@@ -181,7 +408,7 @@ impl WorkerEvent {
/// One typed piece of a user submission. /// One typed piece of a user submission.
/// ///
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb /// `Method::Submit` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
/// clients (CLI piping, scripts) only need to produce a single /// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms /// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (paste chips, file refs) and /// (paste chips, file refs) and
@@ -404,12 +631,13 @@ impl Segment {
} }
impl Method { impl Method {
/// Convenience: a `Run` carrying a single `Segment::Text`. /// Convenience: a `Submit` carrying a single `Segment::Text`.
/// Used by dumb clients, inter-Worker tools, and tests that only have /// Used by dumb clients, inter-Worker tools, and tests that only have
/// a string to forward. /// a string to forward.
pub fn run_text(s: impl Into<String>) -> Self { pub fn submit_text(submission_request_id: impl Into<String>, text: impl Into<String>) -> Self {
Self::Run { Self::Submit {
input: vec![Segment::text(s)], submission_request_id: submission_request_id.into(),
input: vec![Segment::text(text)],
} }
} }
} }
@@ -503,6 +731,39 @@ pub enum ToolResultDisposition {
OutcomeUnknown, OutcomeUnknown,
} }
/// Durable acceptance result for one idempotent Submit request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SubmissionDisposition {
Started,
Queued,
}
/// Bounded public projection of one pending submission. Payload segments and
/// provenance remain in the session log and are intentionally not exposed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct PendingSubmissionSummary {
pub submission_id: String,
pub accepted_at_ms: u64,
pub segment_count: u32,
pub byte_len: u64,
}
/// Revisioned session-owned FIFO projection used by snapshots and live events.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct PendingSubmissionsSnapshot {
pub revision: u64,
#[serde(default)]
pub notification_count: u32,
#[serde(default)]
pub head_id: Option<String>,
#[serde(default)]
pub submissions: Vec<PendingSubmissionSummary>,
}
/// Canonical, storage-independent projection of committed session history. /// Canonical, storage-independent projection of committed session history.
/// ///
/// Worker protocols expose this DTO instead of append-log records. New /// Worker protocols expose this DTO instead of append-log records. New
@@ -511,6 +772,8 @@ pub enum ToolResultDisposition {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionSnapshot { pub struct SessionSnapshot {
#[serde(default)]
pub pending_submissions: PendingSubmissionsSnapshot,
pub entries: Vec<SessionSnapshotEntry>, pub entries: Vec<SessionSnapshotEntry>,
} }
@@ -609,16 +872,28 @@ pub struct SessionToolAttachment {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")] #[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event { pub enum Event {
/// A user input message was accepted, persisted as /// Durable Submit acceptance. A `Queued` receipt follows the durable FIFO
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn. /// checkpoint. A `Started` receipt follows the pending activation checkpoint;
/// Broadcast to every subscribed client so TUI / GUI instances show /// the corresponding [`Event::UserMessage`] marks the later history commit.
/// the same user line that reconnect snapshots would replay from /// Repeating the same request id and exact payload returns the same receipt
/// history; clients must not synthesize a separate pending/fake /// without appending or activating twice.
/// message for accepted runs. SubmissionAccepted {
/// submission_request_id: String,
/// Fires exactly once per committed user input, after submission_id: String,
/// `InvokeStart { kind: UserSend }` and before the first disposition: SubmissionDisposition,
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit. },
/// Correlated rejection before durable acceptance.
SubmissionRejected {
submission_request_id: String,
message: String,
},
/// Revisioned FIFO replacement following enqueue, activation, cancel, or clear.
PendingSubmissionsChanged {
pending: PendingSubmissionsSnapshot,
},
/// A user input message persisted as `LogEntry::AnnotatedUserInput` and
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI
/// instances show the same user line that reconnect snapshots replay.
UserMessage { UserMessage {
segments: Vec<Segment>, segments: Vec<Segment>,
}, },
@@ -641,7 +916,7 @@ pub enum Event {
/// ///
/// Marker event for the start of an Invoke range; the range extends /// Marker event for the start of an Invoke range; the range extends
/// implicitly until the next `InvokeStart`. Fires for every accepted /// implicitly until the next `InvokeStart`. Fires for every accepted
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`), /// `Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other /// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output, /// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
/// typed system reminder insertion that doesn't break IDLE) do not /// typed system reminder insertion that doesn't break IDLE) do not
@@ -798,8 +1073,9 @@ pub enum Event {
Snapshot { Snapshot {
session: SessionSnapshot, session: SessionSnapshot,
greeting: Greeting, greeting: Greeting,
#[serde(default)] /// Full revisioned live execution state. `Stopped` remains Runtime
status: WorkerStatus, /// catalog authority and is deliberately not represented here.
state: WorkerStateSnapshot,
/// Unfinished model output that has already streamed in the current /// Unfinished model output that has already streamed in the current
/// run but is not yet represented by committed snapshot entries. /// run but is not yet represented by committed snapshot entries.
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")] #[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
@@ -836,8 +1112,11 @@ pub enum Event {
}, },
/// Current Worker controller status. Broadcast on every controller-level /// Current Worker controller status. Broadcast on every controller-level
/// transition and included in `History` snapshots for late attach. /// transition and included in `History` snapshots for late attach.
Status { WorkerState {
status: WorkerStatus, snapshot: WorkerStateSnapshot,
},
CommandAcknowledged {
acknowledgement: WorkerCommandAcknowledgement,
}, },
/// Bounded, provider-owned command telemetry for the live Console. This is /// Bounded, provider-owned command telemetry for the live Console. This is
/// intentionally not a history entry and is reconstructed from /// intentionally not a history entry and is reconstructed from
@@ -889,8 +1168,12 @@ pub enum Event {
/// This is not part of LLM history or prompt context; clients may display it /// This is not part of LLM history or prompt context; clients may display it
/// briefly as operational status. /// briefly as operational status.
MemoryWorker(MemoryWorkerEvent), MemoryWorker(MemoryWorkerEvent),
/// Worker has started compacting the current session, or bound the run to its /// Runtime-only compaction progress. `None` clears the current status.
/// observable Internal Worker. Revisions upsert one stable lifecycle item. /// This never enters Session history and carries no operation or Segment identity.
CompactionProgress {
compaction: Option<InFlightCompaction>,
},
/// Legacy compaction lifecycle event retained for wire read compatibility.
CompactStart { CompactStart {
lifecycle: CompactionLifecycle, lifecycle: CompactionLifecycle,
}, },
@@ -1077,6 +1360,32 @@ pub enum CommandEvent {
}, },
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompactionPhase {
Preparing,
Summarizing,
Committing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompactionTrigger {
Manual,
PreRun,
RequestThreshold,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct InFlightCompaction {
pub phase: CompactionPhase,
pub started_at_ms: u64,
pub trigger: CompactionTrigger,
}
/// Unfinished model output and active command state included in /// Unfinished model output and active command state included in
/// `Event::Snapshot` for clients that attach while work is still streaming. /// `Event::Snapshot` for clients that attach while work is still streaming.
/// ///
@@ -1091,11 +1400,17 @@ pub struct InFlightSnapshot {
pub blocks: Vec<InFlightBlock>, pub blocks: Vec<InFlightBlock>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<CommandSnapshot>, pub commands: Vec<CommandSnapshot>,
/// The currently running compaction, if any.
///
/// This is lifecycle progress only. Candidate history and the staged
/// Segment remain private until the Segment is activated atomically.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction: Option<InFlightCompaction>,
} }
impl InFlightSnapshot { impl InFlightSnapshot {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.blocks.is_empty() && self.commands.is_empty() self.blocks.is_empty() && self.commands.is_empty() && self.compaction.is_none()
} }
} }
@@ -1193,7 +1508,7 @@ pub enum TurnResult {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InvokeKind { pub enum InvokeKind {
/// `Method::Run` — a user submission. /// `Method::Submit` — a user submission.
UserSend, UserSend,
/// `Method::Notify` — free-text notification injected into history. /// `Method::Notify` — free-text notification injected into history.
Notify, Notify,
@@ -1216,7 +1531,7 @@ pub enum RunResult {
Finished, Finished,
Paused, Paused,
LimitReached, LimitReached,
/// The accepted Method::Run produced no assistant/tool output before /// The accepted Method::Submit produced no assistant/tool output before
/// user interruption, so the Worker rolled the submit-time turn state back /// user interruption, so the Worker rolled the submit-time turn state back
/// to its pre-submit snapshot. Clients should treat the Worker as Idle and /// to its pre-submit snapshot. Clients should treat the Worker as Idle and
/// restore the just-submitted input into the editable composer if desired. /// restore the just-submitted input into the editable composer if desired.
@@ -1260,12 +1575,30 @@ pub struct ScopeRule {
/// direct children. Defaults to `true`. /// direct children. Defaults to `true`.
#[serde(default = "default_recursive")] #[serde(default = "default_recursive")]
pub recursive: bool, pub recursive: bool,
/// Which path identity an allow rule uses when symbolic links are
/// encountered. Deny rules always inspect both identities.
#[serde(default)]
pub symlink_policy: SymlinkPolicy,
} }
fn default_recursive() -> bool { fn default_recursive() -> bool {
true true
} }
/// Symbolic-link identity used by one filesystem allow rule.
///
/// `Resolved` is the least authority and the default: access is matched
/// against the provider-resolved target. `Logical` intentionally grants the
/// path as presented through the Workdir, even when it aliases another target.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum SymlinkPolicy {
#[default]
Resolved,
Logical,
}
/// Permission lattice used by [`ScopeRule`]. /// Permission lattice used by [`ScopeRule`].
/// ///
/// The derived `Ord` instance follows declaration order, so /// The derived `Ord` instance follows declaration order, so
@@ -1285,26 +1618,70 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn method_run_json_roundtrip() { fn scope_rule_defaults_to_resolved_symlink_policy() {
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"Hello"}]}}"#; let rule: ScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace",
"permission": "read"
}))
.unwrap();
assert!(rule.recursive);
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
let logical: ScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace",
"permission": "read",
"symlink_policy": "logical"
}))
.unwrap();
assert_eq!(logical.symlink_policy, SymlinkPolicy::Logical);
}
#[test]
fn worker_state_snapshot_wire_shape_has_one_authoritative_state() {
let snapshot = WorkerStateSnapshot {
last_command_id: 7,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
};
let value = serde_json::to_value(&snapshot).unwrap();
assert_eq!(
value,
serde_json::json!({
"last_command_id": 7,
"state": {
"kind": "busy",
"state": { "kind": "run", "state": "running" }
}
})
);
assert!(value.get("execution_generation").is_none());
assert!(value.get("revision").is_none());
}
#[test]
fn method_submit_json_roundtrip_and_run_is_rejected() {
let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"Hello"}]}}"#;
let method: Method = serde_json::from_str(json).unwrap(); let method: Method = serde_json::from_str(json).unwrap();
match &method { match &method {
Method::Run { input } => { Method::Submit { input, .. } => {
assert_eq!(input.len(), 1); assert_eq!(input.len(), 1);
match &input[0] { match &input[0] {
Segment::Text { content } => assert_eq!(content, "Hello"), Segment::Text { content } => assert_eq!(content, "Hello"),
other => panic!("expected Text, got {other:?}"), other => panic!("expected Text, got {other:?}"),
} }
} }
other => panic!("expected Run, got {other:?}"), other => panic!("expected Submit, got {other:?}"),
} }
let serialized = serde_json::to_string(&method).unwrap(); let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json); assert_eq!(serialized, json);
assert!(
serde_json::from_str::<Method>(r#"{"method":"run","params":{"input":[]}}"#).is_err()
);
} }
#[test] #[test]
fn method_run_paste_segment_roundtrip() { fn method_submit_paste_segment_roundtrip() {
let method = Method::Run { let method = Method::Submit {
submission_request_id: "request-1".to_string(),
input: vec![ input: vec![
Segment::text("see "), Segment::text("see "),
Segment::Paste { Segment::Paste {
@@ -1318,7 +1695,7 @@ mod tests {
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Method::Run { input } => { Method::Submit { input, .. } => {
assert_eq!(input.len(), 2); assert_eq!(input.len(), 2);
match &input[1] { match &input[1] {
Segment::Paste { Segment::Paste {
@@ -1335,7 +1712,7 @@ mod tests {
other => panic!("expected Paste, got {other:?}"), other => panic!("expected Paste, got {other:?}"),
} }
} }
other => panic!("expected Run, got {other:?}"), other => panic!("expected Submit, got {other:?}"),
} }
} }
@@ -1389,8 +1766,9 @@ mod tests {
} }
#[test] #[test]
fn method_run_flow_segment_roundtrip() { fn method_submit_flow_segment_roundtrip() {
let method = Method::Run { let method = Method::Submit {
submission_request_id: "request-1".to_string(),
input: vec![ input: vec![
Segment::Flow { Segment::Flow {
selector: "builtin:coder-review".to_string(), selector: "builtin:coder-review".to_string(),
@@ -1404,7 +1782,7 @@ mod tests {
let decoded = serde_json::from_str::<Method>(&json).unwrap(); let decoded = serde_json::from_str::<Method>(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Method::Run { input } Method::Submit { input, .. }
if matches!( if matches!(
input.as_slice(), input.as_slice(),
[ [
@@ -1416,15 +1794,26 @@ mod tests {
} }
#[test] #[test]
fn runtime_tracked_run_is_not_public_protocol_json() { fn authenticated_submit_replaces_wire_source_with_transport_identity() {
let method = Method::RunTracked { let method = Method::SubmitTracked {
input: vec![Segment::text("private")], input: vec![Segment::text("private")],
submission_id: "submission-1".to_string(), submission_request_id: "request-1".to_string(),
source: AuthenticatedInputSource::Account {
account_id: "account-1".into(),
},
}; };
assert!(serde_json::to_string(&method).is_err()); let json = serde_json::to_string(&method).unwrap();
let decoded = serde_json::from_str::<Method>(&json).unwrap();
assert!(matches!(
decoded,
Method::SubmitTracked {
source: AuthenticatedInputSource::UntrustedWire,
..
}
));
assert!( assert!(
serde_json::from_str::<Method>( serde_json::from_str::<Method>(
r#"{"method":"run_tracked","input":[],"submission_id":"forged"}"#, r#"{"method":"submit_tracked","input":[],"submission_request_id":"forged"}"#,
) )
.is_err() .is_err()
); );
@@ -1442,42 +1831,46 @@ mod tests {
} }
#[test] #[test]
fn method_run_with_unknown_segment_decodes() { fn method_submit_with_unknown_segment_decodes() {
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#; let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#;
let method: Method = serde_json::from_str(json).unwrap(); let method: Method = serde_json::from_str(json).unwrap();
match method { match method {
Method::Run { input } => { Method::Submit { input, .. } => {
assert_eq!(input.len(), 2); assert_eq!(input.len(), 2);
assert!(matches!(input[0], Segment::Text { .. })); assert!(matches!(input[0], Segment::Text { .. }));
assert!(matches!(input[1], Segment::Unknown)); assert!(matches!(input[1], Segment::Unknown));
} }
other => panic!("expected Run, got {other:?}"), other => panic!("expected Submit, got {other:?}"),
} }
} }
#[test] #[test]
fn method_without_params() { fn lifecycle_method_without_command_fails_closed() {
let json = r#"{"method":"resume"}"#; let error = serde_json::from_str::<Method>(r#"{"method":"resume"}"#).unwrap_err();
let method: Method = serde_json::from_str(json).unwrap(); assert!(error.to_string().contains("params"));
assert!(matches!(method, Method::Resume));
} }
#[test] #[test]
fn method_pause_roundtrip() { fn lifecycle_methods_roundtrip_with_command_identity() {
let json = r#"{"method":"pause"}"#; for method in [
let method: Method = serde_json::from_str(json).unwrap(); Method::Pause {
assert!(matches!(method, Method::Pause)); command: WorkerCommandEnvelope { command_id: 11 },
let serialized = serde_json::to_string(&method).unwrap(); },
assert_eq!(serialized, json); Method::Compact {
} command: WorkerCommandEnvelope { command_id: 12 },
},
#[test] ] {
fn method_compact_roundtrip() { let json = serde_json::to_string(&method).unwrap();
let json = r#"{"method":"compact"}"#; assert!(!json.contains("expected_execution_generation"));
let method: Method = serde_json::from_str(json).unwrap(); assert!(!json.contains("expected_worker_state_revision"));
assert!(matches!(method, Method::Compact)); let decoded: Method = serde_json::from_str(&json).unwrap();
let serialized = serde_json::to_string(&method).unwrap(); match decoded {
assert_eq!(serialized, json); Method::Pause { command } | Method::Compact { command } => {
assert!(command.command_id >= 11);
}
other => panic!("unexpected lifecycle method: {other:?}"),
}
}
} }
#[test] #[test]
@@ -1648,11 +2041,11 @@ mod tests {
#[test] #[test]
fn method_notify_json_roundtrip_defaults_to_auto_run() { fn method_notify_json_roundtrip_defaults_to_auto_run() {
let json = r#"{"method":"notify","params":{"message":"turn done"}}"#; let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"turn done"}}"#;
let method: Method = serde_json::from_str(json).unwrap(); let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!( assert!(matches!(
method, method,
Method::Notify { ref message, auto_run: true } if message == "turn done" Method::Notify { ref message, auto_run: true, .. } if message == "turn done"
)); ));
let serialized = serde_json::to_string(&method).unwrap(); let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json); assert_eq!(serialized, json);
@@ -1660,11 +2053,11 @@ mod tests {
#[test] #[test]
fn method_notify_weak_json_roundtrip_serializes_auto_run_false() { fn method_notify_weak_json_roundtrip_serializes_auto_run_false() {
let json = r#"{"method":"notify","params":{"message":"progress","auto_run":false}}"#; let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"progress","auto_run":false}}"#;
let method: Method = serde_json::from_str(json).unwrap(); let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!( assert!(matches!(
method, method,
Method::Notify { ref message, auto_run: false } if message == "progress" Method::Notify { ref message, auto_run: false, .. } if message == "progress"
)); ));
assert_eq!(serde_json::to_string(&method).unwrap(), json); assert_eq!(serde_json::to_string(&method).unwrap(), json);
} }
@@ -1725,6 +2118,7 @@ mod tests {
fn event_snapshot_format() { fn event_snapshot_format() {
let event = Event::Snapshot { let event = Event::Snapshot {
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: vec![SessionSnapshotEntry { entries: vec![SessionSnapshotEntry {
entry_id: "entry-1".into(), entry_id: "entry-1".into(),
timestamp: 1, timestamp: 1,
@@ -1745,7 +2139,7 @@ mod tests {
context_window: 200_000, context_window: 200_000,
context_tokens: 42_000, context_tokens: 42_000,
}, },
status: WorkerStatus::Paused, state: WorkerStatus::Paused.into(),
in_flight: InFlightSnapshot::default(), in_flight: InFlightSnapshot::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}; };
@@ -1762,12 +2156,13 @@ mod tests {
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read"); assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000); assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000); assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
assert_eq!(parsed["data"]["status"], "paused"); assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy");
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
} }
#[test] #[test]
fn event_snapshot_in_flight_roundtrip_and_default() { fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#; let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap(); let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded { match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()), Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@@ -1776,6 +2171,7 @@ mod tests {
let event = Event::Snapshot { let event = Event::Snapshot {
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting: Greeting { greeting: Greeting {
@@ -1788,7 +2184,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: WorkerStatus::Running, state: WorkerStatus::Running.into(),
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
blocks: vec![ blocks: vec![
InFlightBlock::Text { InFlightBlock::Text {
@@ -1822,6 +2218,11 @@ mod tests {
stderr: CommandStreamSlice::default(), stderr: CommandStreamSlice::default(),
exit_code: None, exit_code: None,
}], }],
compaction: Some(InFlightCompaction {
phase: CompactionPhase::Summarizing,
started_at_ms: 99,
trigger: CompactionTrigger::Manual,
}),
}, },
internal_workers: Vec::new(), internal_workers: Vec::new(),
}; };
@@ -1833,9 +2234,35 @@ mod tests {
parsed["data"]["in_flight"]["blocks"][2]["state"], parsed["data"]["in_flight"]["blocks"][2]["state"],
"streaming_args" "streaming_args"
); );
assert_eq!(
parsed["data"]["in_flight"]["compaction"]["phase"],
"summarizing"
);
assert_eq!(
parsed["data"]["in_flight"]["compaction"]["trigger"],
"manual"
);
assert!(
parsed["data"]["in_flight"]["compaction"]
.as_object()
.is_some_and(|value| {
!value.contains_key("state")
&& !value.contains_key("compaction_id")
&& !value.contains_key("internal_worker")
&& !value.contains_key("summary")
&& !value.contains_key("new_segment_id")
}),
"in-flight compaction progress must not expose terminal or staged state"
);
match serde_json::from_str::<Event>(&json).unwrap() { match serde_json::from_str::<Event>(&json).unwrap() {
Event::Snapshot { in_flight, .. } => assert_eq!(in_flight.blocks.len(), 3), Event::Snapshot { in_flight, .. } => {
assert_eq!(in_flight.blocks.len(), 3);
assert_eq!(
in_flight.compaction.unwrap().phase,
CompactionPhase::Summarizing
);
}
other => panic!("expected Snapshot, got {other:?}"), other => panic!("expected Snapshot, got {other:?}"),
} }
} }
@@ -1844,6 +2271,7 @@ mod tests {
fn event_segment_rotated_roundtrip() { fn event_segment_rotated_roundtrip() {
let event = Event::SegmentRotated { let event = Event::SegmentRotated {
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
}; };
@@ -1875,20 +2303,32 @@ mod tests {
} }
#[test] #[test]
fn event_status_format() { fn event_worker_state_format() {
let event = Event::Status { let event = Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStateSnapshot {
last_command_id: 9,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
},
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "status"); assert_eq!(parsed["event"], "worker_state");
assert_eq!(parsed["data"]["status"], "running"); assert!(
parsed["data"]["snapshot"]
.get("execution_generation")
.is_none()
);
assert!(parsed["data"]["snapshot"].get("revision").is_none());
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Event::Status { Event::WorkerState {
status: WorkerStatus::Running snapshot: WorkerStateSnapshot {
last_command_id: 9,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
}
} }
)); ));
} }
@@ -1929,19 +2369,10 @@ mod tests {
} }
#[test] #[test]
fn event_snapshot_without_status_defaults_to_idle() { fn event_snapshot_without_worker_state_fails_closed() {
let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#; let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
let decoded: Event = serde_json::from_str(json).unwrap(); let error = serde_json::from_str::<Event>(json).unwrap_err();
match decoded { assert!(error.to_string().contains("state"));
Event::Snapshot {
status, greeting, ..
} => {
assert_eq!(status, WorkerStatus::Idle);
assert_eq!(greeting.context_window, 0);
assert_eq!(greeting.context_tokens, 0);
}
other => panic!("expected Snapshot, got {other:?}"),
}
} }
#[test] #[test]
@@ -2037,6 +2468,7 @@ mod tests {
target: "/tmp/work".into(), target: "/tmp/work".into(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}); });
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
@@ -2354,7 +2786,10 @@ mod tests {
"scope_summary": "scope", "scope_summary": "scope",
"tools": [] "tools": []
}, },
"status": "idle" "state": {
"last_command_id": 0,
"state": { "kind": "idle" }
}
} }
})) }))
.unwrap(); .unwrap();
+6
View File
@@ -573,6 +573,11 @@ pub struct SubscriptionWorker {
pub resource_key: Option<String>, pub resource_key: Option<String>,
/// Producer-owned monotonic revision for this Worker subject. /// Producer-owned monotonic revision for this Worker subject.
pub subject_revision: u64, pub subject_revision: u64,
/// Latest revisioned foreground state observed from the Worker. This remains
/// absent until an authoritative Worker snapshot/event has been applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<crate::WorkerStateSnapshot>,
/// Runtime catalog lifecycle compatibility projection; not foreground-state authority.
pub state: SubscriptionWorkerState, pub state: SubscriptionWorkerState,
#[serde(default)] #[serde(default)]
pub has_running_internal_workers: bool, pub has_running_internal_workers: bool,
@@ -874,6 +879,7 @@ mod tests {
runtime_id: None, runtime_id: None,
resource_key: None, resource_key: None,
subject_revision: 0, subject_revision: 0,
worker_state: None,
state: SubscriptionWorkerState::Idle, state: SubscriptionWorkerState::Idle,
has_running_internal_workers: false, has_running_internal_workers: false,
workspace_id: Some("workspace-1".to_string()), workspace_id: Some("workspace-1".to_string()),
+26 -7
View File
@@ -4,15 +4,18 @@ use ts_rs::{Config, TS};
use crate::{ use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream, Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry, CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot, CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot, InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType, InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent, SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
WorkerStatus, UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -46,12 +49,22 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertSource>(&cfg, &mut output); push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output); push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<WorkerStatus>(&cfg, &mut output); push_decl::<WorkerStatus>(&cfg, &mut output);
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
push_decl::<WorkerCommandKind>(&cfg, &mut output);
push_decl::<WorkerCommandDisposition>(&cfg, &mut output);
push_decl::<WorkerCommandAcknowledgement>(&cfg, &mut output);
push_decl::<WorkerRunState>(&cfg, &mut output);
push_decl::<WorkerMaintenanceState>(&cfg, &mut output);
push_decl::<WorkerBusyState>(&cfg, &mut output);
push_decl::<WorkerState>(&cfg, &mut output);
push_decl::<WorkerStateSnapshot>(&cfg, &mut output);
push_decl::<TurnResult>(&cfg, &mut output); push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output); push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output); push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ToolResultDisposition>(&cfg, &mut output); push_decl::<ToolResultDisposition>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output); push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output); push_decl::<Permission>(&cfg, &mut output);
push_decl::<SymlinkPolicy>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output); push_decl::<InFlightToolCallState>(&cfg, &mut output);
push_decl::<CommandStatus>(&cfg, &mut output); push_decl::<CommandStatus>(&cfg, &mut output);
push_decl::<CommandStream>(&cfg, &mut output); push_decl::<CommandStream>(&cfg, &mut output);
@@ -60,6 +73,8 @@ pub fn generated_protocol_types() -> String {
push_decl::<CommandEvent>(&cfg, &mut output); push_decl::<CommandEvent>(&cfg, &mut output);
push_decl::<CompactionLifecycleState>(&cfg, &mut output); push_decl::<CompactionLifecycleState>(&cfg, &mut output);
push_decl::<CompactionLifecycle>(&cfg, &mut output); push_decl::<CompactionLifecycle>(&cfg, &mut output);
push_decl::<CompactionPhase>(&cfg, &mut output);
push_decl::<CompactionTrigger>(&cfg, &mut output);
push_decl::<UploadedFileAvailability>(&cfg, &mut output); push_decl::<UploadedFileAvailability>(&cfg, &mut output);
push_decl::<UploadedFileRef>(&cfg, &mut output); push_decl::<UploadedFileRef>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output); push_decl::<ScopeRule>(&cfg, &mut output);
@@ -68,6 +83,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<RewindTarget>(&cfg, &mut output); push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output); push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output); push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightCompaction>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output); push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<SessionEntryProvenance>(&cfg, &mut output); push_decl::<SessionEntryProvenance>(&cfg, &mut output);
push_decl::<SessionMessageRole>(&cfg, &mut output); push_decl::<SessionMessageRole>(&cfg, &mut output);
@@ -75,6 +91,9 @@ pub fn generated_protocol_types() -> String {
push_decl::<SessionToolAttachment>(&cfg, &mut output); push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output); push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output); push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
push_decl::<SubmissionDisposition>(&cfg, &mut output);
push_decl::<SessionSnapshot>(&cfg, &mut output); push_decl::<SessionSnapshot>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output); push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output); push_decl::<InternalWorkerRef>(&cfg, &mut output);
+3
View File
@@ -8,3 +8,6 @@ license.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+32 -22
View File
@@ -1,29 +1,39 @@
# session-metrics # session-metrics
## Role Session 単位の append-only な観測値を既存 session-log に記録し、明示的な
metrics 読取 / JSONL export 経路で取り出すための小さなヘルパークレートです。
`session-metrics` records usage and memory/session metrics that are useful for diagnostics and maintenance. - 保存先は `session-store``LogEntry::Extension`
- extension domain は `metrics`
- metric は `name / ts / dimensions / value / correlation_id` の最小 envelope
- `record_metric` で指定した Session / Segment に append する
- `read_segment_metrics` は 1 Segment、`read_session_metrics` は Session 内の全
Segment を読み、各 metric に `segment_id``compacted_from` を付ける
- `export_metrics_jsonl` はその located metric を newline-delimited JSON にする
- 通常の Session snapshot / Worker list / Worker detail は Extension を公開しない
## Boundaries compaction は `compact.start` を source Segment、`compact.finish`
`compact.post_request` を結果 Segment に記録する。同じ `correlation_id`
`SegmentStart.compacted_from` により、Segment をまたぐ attempt と次の通常 LLM
request を結合できる。
Owns: ```rust,ignore
use session_metrics::{
Metric, export_metrics_jsonl, read_session_metrics, record_metric,
};
- metric record types and persistence helpers let metric = Metric::now("compact.start")
- explicit memory usage/read/reference observations where applicable .with_value(12_345.0)
- lightweight diagnostic data that should not become model context by itself .with_dimension("trigger", "pre_run")
.with_correlation_id("018f6f8a-9822-7b11-8b35-706f30313700");
record_metric(
&store,
location.session_id,
location.segment_id,
&metric,
)?;
Does not own: let records = read_session_metrics(&store, location.session_id)?;
let jsonl = export_metrics_jsonl(&records)?;
- prompt context packing (`agen`) # Ok::<(), Box<dyn std::error::Error>>(())
- generated memory contents (`memory`) ```
- provider billing semantics (`provider`)
- UI status rendering (`tui`)
## Design notes
Metrics are observations. They may guide compaction, memory effectiveness analysis, or UX, but they are not authoritative conversation history and should not smuggle hidden state into model input.
## See also
- [`../../docs/design/memory-knowledge.md`](../../docs/design/memory-knowledge.md)
- [`../../docs/design/compaction.md`](../../docs/design/compaction.md)
+260 -3
View File
@@ -12,13 +12,16 @@
//! 観測できない値は `None` で明示する //! 観測できない値は `None` で明示する
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に //! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
//! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join //! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join
//! - 集計 / 可視化 API はこのクレートには無い。session-log を読めば取り出せる、 //! - 集計 / 可視化には [`read_session_metrics`] / [`read_segment_metrics`] /
//! までが到達点 //! [`export_metrics_jsonl`] の明示的な metrics 専用経路を使う。通常の
//! Session snapshot は `Extension` を公開しない。
use std::collections::BTreeMap; use std::collections::BTreeMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId, Store, StoreError, save_extension, segment_log}; use session_store::{
LogEntry, SegmentId, SegmentOrigin, SessionId, Store, StoreError, save_extension, segment_log,
};
/// Domain tag used in `LogEntry::Extension` for all metrics records. /// Domain tag used in `LogEntry::Extension` for all metrics records.
pub const DOMAIN: &str = "metrics"; pub const DOMAIN: &str = "metrics";
@@ -97,6 +100,165 @@ pub fn metrics_from_extensions(extensions: &[(String, serde_json::Value)]) -> Ve
.collect() .collect()
} }
/// A metric together with its durable Session/Segment origin.
///
/// `compacted_from` is copied from the Segment start record so readers can
/// reconstruct compaction lineage without inferring relationships from metric
/// names or timestamps.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LocatedMetric {
pub session_id: SessionId,
pub segment_id: SegmentId,
#[serde(skip_serializing_if = "Option::is_none")]
pub compacted_from: Option<SegmentOrigin>,
pub log_index: usize,
pub metric: Metric,
}
#[derive(Debug)]
pub enum SessionMetricsError {
Store(StoreError),
MissingSegmentStart {
segment_id: SegmentId,
},
SessionMismatch {
requested: SessionId,
observed: SessionId,
segment_id: SegmentId,
},
Encode(serde_json::Error),
}
impl std::fmt::Display for SessionMetricsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Store(error) => write!(formatter, "session metrics store error: {error}"),
Self::MissingSegmentStart { segment_id } => {
write!(formatter, "segment {segment_id} has no start record")
}
Self::SessionMismatch {
requested,
observed,
segment_id,
} => write!(
formatter,
"segment {segment_id} belongs to session {observed}, not {requested}"
),
Self::Encode(error) => write!(formatter, "session metrics encode error: {error}"),
}
}
}
impl std::error::Error for SessionMetricsError {}
impl From<StoreError> for SessionMetricsError {
fn from(error: StoreError) -> Self {
Self::Store(error)
}
}
impl From<serde_json::Error> for SessionMetricsError {
fn from(error: serde_json::Error) -> Self {
Self::Encode(error)
}
}
/// Read metrics from one exact Segment.
///
/// This is an explicit metrics-only surface. It validates the Segment's
/// durable start record and retains the log position of each metric.
pub fn read_segment_metrics(
store: &dyn Store,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
let entries = store.read_all(session_id, segment_id)?;
let (observed_session_id, compacted_from) = entries
.iter()
.find_map(|entry| match entry {
LogEntry::AnnotatedSegmentStart {
session_id,
compacted_from,
..
} => Some((*session_id, compacted_from.clone())),
_ => None,
})
.ok_or(SessionMetricsError::MissingSegmentStart { segment_id })?;
if observed_session_id != session_id {
return Err(SessionMetricsError::SessionMismatch {
requested: session_id,
observed: observed_session_id,
segment_id,
});
}
Ok(entries
.iter()
.enumerate()
.filter_map(|(log_index, entry)| match entry {
LogEntry::Extension {
domain, payload, ..
} if domain == DOMAIN => {
serde_json::from_value::<Metric>(payload.clone())
.ok()
.map(|metric| LocatedMetric {
session_id,
segment_id,
compacted_from: compacted_from.clone(),
log_index,
metric,
})
}
_ => None,
})
.collect())
}
/// Read every metric for a Session across all of its Segments.
pub fn read_session_metrics(
store: &dyn Store,
session_id: SessionId,
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
let mut metrics = Vec::new();
for segment_id in store.list_segments(session_id)? {
metrics.extend(read_segment_metrics(store, session_id, segment_id)?);
}
metrics.sort_by(|left, right| {
(
left.metric.ts,
metric_phase_order(&left.metric.name),
left.segment_id,
left.log_index,
)
.cmp(&(
right.metric.ts,
metric_phase_order(&right.metric.name),
right.segment_id,
right.log_index,
))
});
Ok(metrics)
}
/// Serialize located metrics as newline-delimited JSON for an explicit export.
pub fn export_metrics_jsonl(metrics: &[LocatedMetric]) -> Result<String, SessionMetricsError> {
let mut output = String::new();
for metric in metrics {
output.push_str(&serde_json::to_string(metric)?);
output.push('\n');
}
Ok(output)
}
fn metric_phase_order(name: &str) -> u8 {
match name {
"compact.start" => 0,
"compact.finish" => 2,
"compact.post_request" => 3,
_ => 1,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -151,6 +313,101 @@ mod tests {
assert_eq!(metrics[1].name, "b"); assert_eq!(metrics[1].name, "b");
} }
#[test]
fn explicit_reader_and_export_preserve_compaction_lineage() {
use session_store::FsStore;
let temp = tempfile::tempdir().unwrap();
let store = FsStore::new(temp.path()).unwrap();
let session_id = SessionId::parse_str("018f6f8a-9822-7b11-8b35-706f30313701").unwrap();
let source_segment_id =
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313702").unwrap();
let result_segment_id =
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313703").unwrap();
let correlation_id = "018f6f8a-9822-7b11-8b35-706f30313700";
store
.create_segment(
session_id,
source_segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: Default::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
let mut start = Metric::now("compact.start").with_correlation_id(correlation_id);
start.ts = 10;
record_metric(&store, session_id, source_segment_id, &start).unwrap();
let origin = SegmentOrigin {
segment_id: source_segment_id,
at_turn_index: 0,
};
store
.create_segment(
session_id,
result_segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 2,
session_id,
system_prompt: None,
config: Default::default(),
history: Vec::new(),
forked_from: None,
compacted_from: Some(origin.clone()),
}],
)
.unwrap();
let mut finish = Metric::now("compact.finish").with_correlation_id(correlation_id);
finish.ts = 10;
record_metric(&store, session_id, result_segment_id, &finish).unwrap();
let mut post = Metric::now("compact.post_request").with_correlation_id(correlation_id);
post.ts = 11;
record_metric(&store, session_id, result_segment_id, &post).unwrap();
let source_metrics = read_segment_metrics(&store, session_id, source_segment_id).unwrap();
assert_eq!(source_metrics.len(), 1);
assert_eq!(source_metrics[0].compacted_from, None);
let metrics = read_session_metrics(&store, session_id).unwrap();
assert_eq!(metrics.len(), 3);
assert_eq!(metrics[0].metric.name, "compact.start");
let finish = metrics
.iter()
.find(|record| record.metric.name == "compact.finish")
.unwrap();
assert_eq!(finish.segment_id, result_segment_id);
assert_eq!(finish.compacted_from, Some(origin));
assert!(
metrics
.iter()
.all(|record| { record.metric.correlation_id.as_deref() == Some(correlation_id) })
);
let exported = export_metrics_jsonl(&metrics).unwrap();
let ordinary_snapshot = session_store::public_snapshot::project_current_session_snapshot(
&store.read_all(session_id, result_segment_id).unwrap(),
);
let ordinary_json = serde_json::to_string(&ordinary_snapshot).unwrap();
assert!(!ordinary_json.contains("compact.finish"));
assert!(!ordinary_json.contains("compact.post_request"));
let decoded = exported
.lines()
.map(|line| serde_json::from_str::<LocatedMetric>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(decoded, metrics);
let reopened = FsStore::new(temp.path()).unwrap();
let restored = read_session_metrics(&reopened, session_id).unwrap();
assert_eq!(restored, metrics);
}
#[test] #[test]
fn fold_skips_undeserializable_payloads() { fn fold_skips_undeserializable_payloads() {
// 将来 schema が変わって読めない payload も skip して落ちない。 // 将来 schema が変わって読めない payload も skip して落ちない。
+171 -9
View File
@@ -21,8 +21,10 @@ use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError}; use crate::store::{Store, StoreError};
use crate::uploaded_file::{ use crate::uploaded_file::{
bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files, bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files,
delete_uncommitted_uploaded_files, delete_uploaded_file, list_uploaded_file_refs, delete_uncommitted_uploaded_files, delete_uploaded_file, finalize_uploaded_file_binding,
read_uploaded_file, read_uploaded_file_by_id, write_uploaded_file, list_uploaded_file_refs, pin_uploaded_file, read_uploaded_file, read_uploaded_file_by_id,
reconcile_uploaded_file_pins, release_uploaded_file_pin, uploaded_file_has_pending_owner,
write_uploaded_file,
}; };
use crate::{ use crate::{
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext, PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
@@ -518,6 +520,61 @@ impl Store for FsStore {
} }
} }
fn pin_uploaded_file(
&self,
session_id: SessionId,
reference: &UploadedFileRef,
owner_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
pin_uploaded_file(&self.paste_artifact_dir(session_id), reference, owner_id)
}
fn release_uploaded_file_pin(
&self,
session_id: SessionId,
artifact_id: &str,
owner_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
release_uploaded_file_pin(&self.paste_artifact_dir(session_id), artifact_id, owner_id)
}
fn finalize_uploaded_file_binding(
&self,
session_id: SessionId,
artifact_id: &str,
source_entry_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
finalize_uploaded_file_binding(
&self.paste_artifact_dir(session_id),
artifact_id,
source_entry_id,
)
}
fn reconcile_uploaded_file_pins(
&self,
session_id: SessionId,
live_owner_ids: &[String],
) -> Result<u64, StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
reconcile_uploaded_file_pins(&self.paste_artifact_dir(session_id), live_owner_ids)
}
fn delete_uploaded_file( fn delete_uploaded_file(
&self, &self,
session_id: SessionId, session_id: SessionId,
@@ -541,13 +598,18 @@ impl Store for FsStore {
let Some(source_entry_id) = reference.source_entry_id.as_deref() else { let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
continue; continue;
}; };
if !self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? { if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?; finalize_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
if delete_uploaded_file(&dir, &reference.artifact_id)? { continue;
removed = removed }
.checked_add(1) if uploaded_file_has_pending_owner(&dir, &reference.artifact_id)? {
.ok_or(StoreError::ArtifactQuotaExceeded)?; 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) Ok(removed)
@@ -865,6 +927,106 @@ mod tests {
assert!(store.read_uploaded_file(owner, &reference).is_err()); assert!(store.read_uploaded_file(owner, &reference).is_err());
} }
#[test]
fn pending_upload_pin_survives_cleanup_until_release_or_history_binding() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsStore::new(tmp.path()).unwrap();
let session_id = new_session_id();
let limits = UploadedFileLimits {
max_file_bytes: 64,
max_session_bytes: 128,
};
let pending = store
.write_uploaded_file(session_id, "pending.txt", "text/plain", b"pending", limits)
.unwrap();
store
.pin_uploaded_file(session_id, &pending, "submission-1")
.unwrap();
assert!(matches!(
store.pin_uploaded_file(session_id, &pending, "submission-other"),
Err(StoreError::ArtifactAlreadyCommitted)
));
drop(store);
let store = FsStore::new(tmp.path()).unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert_eq!(
store
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
.unwrap()
.1,
b"pending"
);
let fork_session_id = new_session_id();
assert_eq!(
store
.copy_committed_uploaded_files(session_id, fork_session_id)
.unwrap(),
0
);
assert!(
store
.read_uploaded_file_by_id(fork_session_id, &pending.artifact_id)
.is_err()
);
let committed = store
.bind_uploaded_file(session_id, &pending, "entry-1")
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert!(
store
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
.is_ok()
);
store
.create_segment(
session_id,
new_segment_id(),
&[LogEntry::InputSegmentsCheckpoint {
ts: 1,
user_segments: vec![vec![protocol::Segment::UploadedFile {
file: committed.clone(),
}]],
}],
)
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert!(
store
.release_uploaded_file_pin(session_id, &pending.artifact_id, "submission-1")
.is_err()
);
let releasable = store
.write_uploaded_file(session_id, "cancelled.txt", "text/plain", b"cancel", limits)
.unwrap();
store
.pin_uploaded_file(session_id, &releasable, "submission-2")
.unwrap();
store
.release_uploaded_file_pin(session_id, &releasable.artifact_id, "submission-2")
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
1
);
assert!(
store
.read_uploaded_file_by_id(session_id, &releasable.artifact_id)
.is_err()
);
}
#[test] #[test]
fn uploaded_file_validation_and_shared_quota_fail_closed() { fn uploaded_file_validation_and_shared_quota_fail_closed() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
@@ -183,6 +183,7 @@ fn canonicalize_history_entry(
item, item,
metadata: legacy_metadata(segment_id, line_index, 0), metadata: legacy_metadata(segment_id, line_index, 0),
}, },
extensions: Vec::new(),
}, },
} }
} }
+5 -2
View File
@@ -71,7 +71,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
entries.push(history_entry(entry, *ts, data)); entries.push(history_entry(entry, *ts, data));
} }
} }
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry( LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
&entry.item, &entry.item,
entry.metadata.entry_id.0.clone(), entry.metadata.entry_id.0.clone(),
*ts, *ts,
@@ -100,7 +100,10 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
} }
} }
SessionSnapshot { entries } SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries,
}
} }
fn extend_history( fn extend_history(
+1
View File
@@ -287,6 +287,7 @@ pub fn append_system_item(
LogEntry::AnnotatedSystemItem { LogEntry::AnnotatedSystemItem {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
entry, entry,
extensions: Vec::new(),
}, },
) )
} }
+10 -1
View File
@@ -112,6 +112,8 @@ pub enum LogEntry {
AnnotatedSystemItem { AnnotatedSystemItem {
ts: u64, ts: u64,
entry: LoggedSystemHistoryEntry, entry: LoggedSystemHistoryEntry,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
}, },
/// Turn boundary. Records the turn count after increment. /// Turn boundary. Records the turn count after increment.
@@ -312,12 +314,19 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.annotated_history.push(entry.clone()); state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone())); state.history.push(Item::from(entry.item.clone()));
} }
LogEntry::AnnotatedSystemItem { entry, .. } => { LogEntry::AnnotatedSystemItem {
entry, extensions, ..
} => {
state.annotated_history.push(LoggedHistoryEntry { state.annotated_history.push(LoggedHistoryEntry {
item: LoggedItem::from(entry.item.to_history_item()), item: LoggedItem::from(entry.item.to_history_item()),
metadata: entry.metadata.clone(), metadata: entry.metadata.clone(),
}); });
state.history.push(entry.item.to_history_item()); state.history.push(entry.item.to_history_item());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
} }
LogEntry::TurnEnd { turn_count, .. } => { LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count { if let Some(active_turn_count) = &mut state.active_run_turn_count {
+41
View File
@@ -226,6 +226,47 @@ pub trait Store: Send + Sync {
Err(StoreError::PasteArtifactUnsupported) Err(StoreError::PasteArtifactUnsupported)
} }
/// Retain an uploaded file while a durable pending operation owns it.
fn pin_uploaded_file(
&self,
_session_id: SessionId,
_reference: &UploadedFileRef,
_owner_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Release a pending-operation pin without changing committed ownership.
fn release_uploaded_file_pin(
&self,
_session_id: SessionId,
_artifact_id: &str,
_owner_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Complete the pending-to-history handoff after the history entry commits.
fn finalize_uploaded_file_binding(
&self,
_session_id: SessionId,
_artifact_id: &str,
_source_entry_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Clear pending-operation pins that have no owner in restored durable
/// Worker Session state. This repairs an interrupted pin-before-checkpoint
/// acceptance without disturbing live queue owners or committed history.
fn reconcile_uploaded_file_pins(
&self,
_session_id: SessionId,
_live_owner_ids: &[String],
) -> Result<u64, StoreError> {
Ok(0)
}
/// Delete an uncommitted uploaded file owned by `session_id`. /// Delete an uncommitted uploaded file owned by `session_id`.
fn delete_uploaded_file( fn delete_uploaded_file(
&self, &self,
+143 -2
View File
@@ -24,6 +24,12 @@ pub const DEFAULT_MAX_FILES_PER_SUBMISSION: usize = 8;
pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256; pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256;
const MAX_FILE_NAME_CHARS: usize = 255; const MAX_FILE_NAME_CHARS: usize = 255;
const MAX_MEDIA_TYPE_BYTES: usize = 127; const MAX_MEDIA_TYPE_BYTES: usize = 127;
fn validate_pending_owner_id(owner_id: &str) -> Result<()> {
if owner_id.is_empty() || owner_id.len() > 256 {
return Err(StoreError::ArtifactIntegrityMismatch);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UploadedFileLimits { pub struct UploadedFileLimits {
@@ -59,6 +65,8 @@ struct StoredUploadedFile {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
source_entry_id: Option<String>, source_entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pending_owner_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
upload_context: Option<UploadedFileUploadContext>, upload_context: Option<UploadedFileUploadContext>,
content_base64: String, content_base64: String,
} }
@@ -291,6 +299,7 @@ pub(crate) fn write_uploaded_file(
byte_len, byte_len,
sha256: sha256.clone(), sha256: sha256.clone(),
source_entry_id: None, source_entry_id: None,
pending_owner_id: None,
upload_context: context.cloned(), upload_context: context.cloned(),
content_base64: BASE64.encode(content), content_base64: BASE64.encode(content),
}; };
@@ -338,6 +347,12 @@ pub(crate) fn read_uploaded_file_by_id(
Ok((reference, content)) Ok((reference, content))
} }
pub(crate) fn uploaded_file_has_pending_owner(dir: &Path, artifact_id: &str) -> Result<bool> {
let path = record_path(dir, artifact_id)?;
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(path)?)?;
Ok(stored.pending_owner_id.is_some())
}
pub(crate) fn read_uploaded_file(dir: &Path, reference: &UploadedFileRef) -> Result<Vec<u8>> { pub(crate) fn read_uploaded_file(dir: &Path, reference: &UploadedFileRef) -> Result<Vec<u8>> {
let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?; let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
if stored_reference.file_name != reference.file_name if stored_reference.file_name != reference.file_name
@@ -376,6 +391,98 @@ pub(crate) fn clear_uploaded_file_binding(
Ok(()) Ok(())
} }
pub(crate) fn pin_uploaded_file(
dir: &Path,
reference: &UploadedFileRef,
owner_id: &str,
) -> Result<()> {
validate_pending_owner_id(owner_id)?;
if reference.source_entry_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, &reference.artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.file_name != reference.file_name
|| stored.media_type != reference.media_type
|| stored.created_at_ms != reference.created_at_ms
|| stored.byte_len != reference.byte_len
|| stored.sha256 != reference.sha256
{
return Err(StoreError::ArtifactIntegrityMismatch);
}
if stored.source_entry_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
if let Some(existing_owner) = stored.pending_owner_id.as_deref() {
return if existing_owner == owner_id {
Ok(())
} else {
Err(StoreError::ArtifactAlreadyCommitted)
};
}
stored.pending_owner_id = Some(owner_id.to_owned());
let temp = dir.join(format!(".{}.file.pin.tmp", reference.artifact_id));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn release_uploaded_file_pin(
dir: &Path,
artifact_id: &str,
owner_id: &str,
) -> Result<()> {
validate_pending_owner_id(owner_id)?;
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.pending_owner_id.as_deref() != Some(owner_id) {
return Err(StoreError::ArtifactIntegrityMismatch);
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.unpin.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn finalize_uploaded_file_binding(
dir: &Path,
artifact_id: &str,
source_entry_id: &str,
) -> Result<()> {
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.source_entry_id.as_deref() != Some(source_entry_id) {
return Err(StoreError::ArtifactIntegrityMismatch);
}
if stored.pending_owner_id.is_none() {
return Ok(());
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.finalize.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn bind_uploaded_file( pub(crate) fn bind_uploaded_file(
dir: &Path, dir: &Path,
reference: &UploadedFileRef, reference: &UploadedFileRef,
@@ -479,6 +586,40 @@ pub(crate) fn copy_committed_uploaded_files(source_dir: &Path, target_dir: &Path
Ok(copied) Ok(copied)
} }
pub(crate) fn reconcile_uploaded_file_pins(dir: &Path, live_owner_ids: &[String]) -> Result<u64> {
fs::create_dir_all(dir)?;
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let mut reconciled = 0_u64;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(artifact_id) = file_name.strip_suffix(".file.json") else {
continue;
};
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
let Some(owner_id) = stored.pending_owner_id.as_deref() else {
continue;
};
if live_owner_ids.iter().any(|live| live == owner_id) {
continue;
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.reconcile.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
reconciled = reconciled.saturating_add(1);
}
Ok(reconciled)
}
pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> { pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
let aggregate_lock = fs::OpenOptions::new() let aggregate_lock = fs::OpenOptions::new()
@@ -499,7 +640,7 @@ pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
continue; continue;
} }
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?; let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.source_entry_id.is_none() { if stored.source_entry_id.is_none() && stored.pending_owner_id.is_none() {
fs::remove_file(path)?; fs::remove_file(path)?;
removed = removed removed = removed
.checked_add(1) .checked_add(1)
@@ -523,7 +664,7 @@ pub(crate) fn delete_uploaded_file(dir: &Path, artifact_id: &str) -> Result<bool
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
}; };
if stored.source_entry_id.is_some() { if stored.source_entry_id.is_some() || stored.pending_owner_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted); return Err(StoreError::ArtifactAlreadyCommitted);
} }
match fs::remove_file(path) { match fs::remove_file(path) {
+375 -8
View File
@@ -14,8 +14,24 @@
use crate::{SegmentId, SessionId}; use crate::{SegmentId, SessionId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, Weak};
fn metadata_lock(path: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
let mut locks = LOCKS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.expect("metadata lock registry poisoned");
if let Some(lock) = locks.get(path).and_then(Weak::upgrade) {
return lock;
}
let lock = Arc::new(Mutex::new(()));
locks.insert(path.to_path_buf(), Arc::downgrade(&lock));
lock
}
/// Errors from Worker metadata persistence. /// Errors from Worker metadata persistence.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -63,6 +79,8 @@ pub struct WorkerSpawnedScopeRule {
pub target: PathBuf, pub target: PathBuf,
pub permission: String, pub permission: String,
pub recursive: bool, pub recursive: bool,
#[serde(default)]
pub symlink_policy: protocol::SymlinkPolicy,
} }
/// One child Worker spawned by this Worker and persisted with the spawner's /// One child Worker spawned by this Worker and persisted with the spawner's
@@ -180,6 +198,24 @@ pub trait WorkerMetadataStore: Send + Sync {
Ok(metadata) Ok(metadata)
} }
/// Compare and swap the active Segment pointer while preserving unrelated metadata.
/// Returns `false` without mutation when the durable pointer no longer matches.
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let mut matched = false;
self.update_by_name(worker_name, |metadata| {
if metadata.active.as_ref() == Some(expected) {
metadata.active = Some(replacement);
matched = true;
}
})?;
Ok(matched)
}
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot. /// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
fn set_active( fn set_active(
&self, &self,
@@ -328,6 +364,7 @@ pub trait WorkerMetadataStore: Send + Sync {
pub struct WorkerAggregateStore { pub struct WorkerAggregateStore {
root: PathBuf, root: PathBuf,
worker_name: String, worker_name: String,
update_lock: Arc<Mutex<()>>,
} }
impl WorkerAggregateStore { impl WorkerAggregateStore {
@@ -339,7 +376,11 @@ impl WorkerAggregateStore {
let worker_name = worker_name.into(); let worker_name = worker_name.into();
validate_worker_name(&worker_name)?; validate_worker_name(&worker_name)?;
fs::create_dir_all(&root)?; fs::create_dir_all(&root)?;
Ok(Self { root, worker_name }) Ok(Self {
update_lock: metadata_lock(&root),
root,
worker_name,
})
} }
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> { fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
@@ -406,6 +447,47 @@ impl WorkerMetadataStore for WorkerAggregateStore {
Ok(Some(metadata)) Ok(Some(metadata))
} }
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let mut metadata = self
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
update(&mut metadata);
self.write(&metadata)?;
Ok(metadata)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let Some(mut metadata) = self.read_by_name(worker_name)? else {
return Ok(false);
};
if metadata.active.as_ref() != Some(expected) {
return Ok(false);
}
metadata.active = Some(replacement);
self.write(&metadata)?;
Ok(true)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
Ok(if self.metadata_path().is_file() { Ok(if self.metadata_path().is_file() {
vec![self.worker_name.clone()] vec![self.worker_name.clone()]
@@ -432,6 +514,7 @@ impl WorkerMetadataStore for WorkerAggregateStore {
#[derive(Clone)] #[derive(Clone)]
pub struct FsWorkerStore { pub struct FsWorkerStore {
root: PathBuf, root: PathBuf,
update_lock: Arc<Mutex<()>>,
} }
impl FsWorkerStore { impl FsWorkerStore {
@@ -439,7 +522,10 @@ impl FsWorkerStore {
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> { pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
let root = root.into(); let root = root.into();
fs::create_dir_all(&root)?; fs::create_dir_all(&root)?;
Ok(Self { root }) Ok(Self {
update_lock: metadata_lock(&root),
root,
})
} }
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> { fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
@@ -455,12 +541,32 @@ impl FsWorkerStore {
impl WorkerMetadataStore for FsWorkerStore { impl WorkerMetadataStore for FsWorkerStore {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
let path = self.metadata_path(&metadata.worker_name)?; let path = self.metadata_path(&metadata.worker_name)?;
if let Some(parent) = path.parent() { let mut content = serde_json::to_vec_pretty(metadata)?;
fs::create_dir_all(parent)?; content.push(b'\n');
let parent = path.parent().expect("metadata path has parent");
fs::create_dir_all(parent)?;
let temp = parent.join(format!(
".metadata.json.tmp-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
let result = (|| -> Result<(), WorkerStoreError> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)?;
file.write_all(&content)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(temp);
} }
let content = serde_json::to_vec_pretty(metadata)?; result
fs::write(path, content)?;
Ok(())
} }
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
@@ -473,6 +579,47 @@ impl WorkerMetadataStore for FsWorkerStore {
Ok(Some(serde_json::from_str(&content)?)) Ok(Some(serde_json::from_str(&content)?))
} }
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let mut metadata = self
.read_by_name(worker_name)?
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
update(&mut metadata);
self.write(&metadata)?;
Ok(metadata)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
let _guard = self
.update_lock
.lock()
.expect("metadata update lock poisoned");
let Some(mut metadata) = self.read_by_name(worker_name)? else {
return Ok(false);
};
if metadata.active.as_ref() != Some(expected) {
return Ok(false);
}
metadata.active = Some(replacement);
self.write(&metadata)?;
Ok(true)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
let mut names = Vec::new(); let mut names = Vec::new();
if !self.root.exists() { if !self.root.exists() {
@@ -648,6 +795,26 @@ where
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.worker_metadata_store.read_by_name(worker_name) self.worker_metadata_store.read_by_name(worker_name)
} }
fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where
F: FnOnce(&mut WorkerMetadata),
{
self.worker_metadata_store
.update_by_name(worker_name, update)
}
fn compare_and_swap_active(
&self,
worker_name: &str,
expected: &WorkerActiveSegmentRef,
replacement: WorkerActiveSegmentRef,
) -> Result<bool, WorkerStoreError> {
self.worker_metadata_store
.compare_and_swap_active(worker_name, expected, replacement)
}
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
self.worker_metadata_store.list_names() self.worker_metadata_store.list_names()
} }
@@ -662,6 +829,7 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{LogEntry, Store};
#[test] #[test]
fn worker_metadata_manifest_snapshot_roundtrips() { fn worker_metadata_manifest_snapshot_roundtrips() {
@@ -682,6 +850,25 @@ mod tests {
assert_eq!(restored, metadata); assert_eq!(restored, metadata);
} }
#[test]
fn spawned_scope_rule_defaults_resolved_and_roundtrips_logical_policy() {
let legacy: WorkerSpawnedScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace/src",
"permission": "read",
"recursive": true
}))
.unwrap();
assert_eq!(legacy.symlink_policy, protocol::SymlinkPolicy::Resolved);
let logical = WorkerSpawnedScopeRule {
symlink_policy: protocol::SymlinkPolicy::Logical,
..legacy
};
let restored: WorkerSpawnedScopeRule =
serde_json::from_value(serde_json::to_value(&logical).unwrap()).unwrap();
assert_eq!(restored, logical);
}
#[test] #[test]
fn worker_aggregate_store_writes_one_fixed_metadata_identity() { fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -835,6 +1022,7 @@ mod tests {
target: std::path::Path::new("/tmp/delegated").into(), target: std::path::Path::new("/tmp/delegated").into(),
permission: "write".into(), permission: "write".into(),
recursive: true, recursive: true,
symlink_policy: Default::default(),
}; };
store store
.set_spawned_children( .set_spawned_children(
@@ -862,4 +1050,183 @@ mod tests {
assert_eq!(restored.reclaimed_children.len(), 1); assert_eq!(restored.reclaimed_children.len(), 1);
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]); assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
} }
#[test]
fn staged_segment_is_invisible_until_cas_and_reopen_selects_committed_history() {
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let workers = temp.path().join("workers");
let open = || {
CombinedStore::new(
crate::FsStore::new(&sessions).unwrap(),
FsWorkerStore::new(&workers).unwrap(),
)
};
let store = open();
let session_id = crate::new_session_id();
let old_segment_id = crate::new_segment_id();
let new_segment_id = crate::new_segment_id();
let entry = |label: &str| LogEntry::Extension {
ts: 1,
domain: label.into(),
payload: serde_json::json!({}),
};
store
.create_segment(session_id, old_segment_id, &[entry("old-history")])
.unwrap();
store
.write(&WorkerMetadata::new(
"agent",
Some(WorkerActiveSegmentRef::active_segment(
session_id,
old_segment_id,
)),
))
.unwrap();
store
.create_segment(session_id, new_segment_id, &[entry("new-history")])
.unwrap();
drop(store);
let reopened = open();
assert_eq!(
reopened
.read_by_name("agent")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(old_segment_id)
);
assert!(
reopened
.compare_and_swap_active(
"agent",
&WorkerActiveSegmentRef::active_segment(session_id, old_segment_id),
WorkerActiveSegmentRef::active_segment(session_id, new_segment_id),
)
.unwrap()
);
drop(reopened);
let reopened = open();
assert_eq!(
reopened
.read_by_name("agent")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(new_segment_id)
);
assert!(matches!(
reopened.read_all(session_id, new_segment_id).unwrap().as_slice(),
[LogEntry::Extension { domain, .. }] if domain == "new-history"
));
}
#[test]
fn aggregate_store_uses_expected_old_segment_cas() {
let temp = tempfile::tempdir().unwrap();
let store = WorkerAggregateStore::new(temp.path(), "agent").unwrap();
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
assert!(
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
)
.unwrap()
);
assert!(
!store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
)
.unwrap()
);
}
#[test]
fn combined_store_delegates_atomic_active_segment_cas() {
let temp = tempfile::tempdir().unwrap();
let metadata = FsWorkerStore::new(temp.path().join("workers")).unwrap();
let store = CombinedStore::new(
crate::FsStore::new(temp.path().join("sessions")).unwrap(),
metadata,
);
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
let store = store.clone();
let old = old.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
)
.unwrap()
})
});
barrier.wait();
assert_eq!(
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|won| *won)
.count(),
1
);
}
#[test]
fn active_segment_cas_allows_exactly_one_concurrent_winner() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkerStore::new(temp.path()).unwrap();
let session_id = crate::new_session_id();
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
store
.write(&WorkerMetadata::new("agent", Some(old.clone())))
.unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
let store = store.clone();
let old = old.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store
.compare_and_swap_active(
"agent",
&old,
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
)
.unwrap()
})
});
barrier.wait();
let winners = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|won| *won)
.count();
assert_eq!(winners, 1);
}
} }
+9 -5
View File
@@ -3,7 +3,7 @@ mod common;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::sync::Arc; use std::sync::Arc;
use agen::interceptor::{Interceptor, TurnEndAction}; use agen::interceptor::{AssistantTurnEndContext, Interceptor, InterceptorResult, TurnEndAction};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::types::{Item, RequestConfig}; use agen::llm_client::types::{Item, RequestConfig};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
@@ -100,8 +100,11 @@ struct PausePolicy;
#[async_trait] #[async_trait]
impl Interceptor for PausePolicy { impl Interceptor for PausePolicy {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction { async fn on_assistant_turn_end(
TurnEndAction::Pause &self,
_context: AssistantTurnEndContext<'_>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Pause)
} }
} }
@@ -350,7 +353,8 @@ async fn session_run_with_tool_call() {
async fn session_resume_after_pause() { async fn session_resume_after_pause() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
// First run: tool call with pause policy → Paused // First terminal assistant response requests a tool; the assistant-turn
// interceptor pauses before the Engine enters the tool phase.
let client = MockLlmClient::with_responses(tool_call_events()); let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = TestWorker::new(Engine::new(client)); let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition()); worker.register_tool(weather_tool_definition());
@@ -386,7 +390,7 @@ async fn session_resume_after_pause() {
// Restore state and verify // Restore state and verify
let state = session_store::restore(&store, sid, segid).unwrap(); let state = session_store::restore(&store, sid, segid).unwrap();
assert!(state.last_run_interrupted); assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, Some(2)); assert_eq!(state.active_run_turn_count, Some(1));
} }
#[tokio::test] #[tokio::test]
+4 -2
View File
@@ -318,7 +318,8 @@ impl StandaloneHost {
} }
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> { pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
let _ = self.handle.send(Method::Shutdown).await; let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
let _ = self.handle.send(Method::Shutdown { command }).await;
let Some(shutdown) = self.shutdown.take() else { let Some(shutdown) = self.shutdown.take() else {
self.retain_lease(); self.retain_lease();
return Err(StandaloneShutdownError::ConfirmationLost); return Err(StandaloneShutdownError::ConfirmationLost);
@@ -500,7 +501,8 @@ fn active_pointer(
} }
async fn stop_started_worker(started: BootstrappedWorker) { async fn stop_started_worker(started: BootstrappedWorker) {
let _ = started.handle.send(Method::Shutdown).await; let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
let _ = started.handle.send(Method::Shutdown { command }).await;
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await; let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
} }
+111 -6
View File
@@ -191,8 +191,7 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error) StandaloneStoreError::Io(error)
} }
})?; })?;
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes) let record = decode_worker_record(id, &bytes)?;
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
if record.schema_version > SCHEMA_VERSION { if record.schema_version > SCHEMA_VERSION {
return Err(StandaloneStoreError::NewerSchema { return Err(StandaloneStoreError::NewerSchema {
id, id,
@@ -408,7 +407,7 @@ impl StandaloneWorkerStore {
.create_new(true) .create_new(true)
.open(&temporary) .open(&temporary)
.map_err(StandaloneStoreError::Io)?; .map_err(StandaloneStoreError::Io)?;
serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?; write_worker_record(&mut file, next)?;
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?; file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?; file.sync_all().map_err(StandaloneStoreError::Io)?;
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
@@ -428,8 +427,7 @@ impl StandaloneWorkerStore {
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let bytes = let bytes =
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes) decode_worker_record(id, &bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
} }
fn worker_dir(&self, id: WorkerId) -> PathBuf { fn worker_dir(&self, id: WorkerId) -> PathBuf {
@@ -634,6 +632,50 @@ fn observe_process(pid: u32) -> ProcessObservation {
} }
} }
fn decode_worker_record(
id: WorkerId,
bytes: &[u8],
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let decode = || -> Result<StandaloneWorkerRecord, serde_json::Error> {
let mut snapshot: serde_json::Value = serde_json::from_slice(bytes)?;
let object = snapshot.as_object_mut().ok_or_else(|| {
serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record must be an object",
))
})?;
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record is missing manifest",
))
})?;
let manifest = manifest::read_persisted_worker_manifest_snapshot(persisted_manifest)?;
object.insert("manifest".to_string(), serde_json::to_value(manifest)?);
serde_json::from_value(snapshot)
};
decode().map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
}
fn write_worker_record(
writer: &mut impl Write,
record: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> {
let mut snapshot = serde_json::to_value(record).map_err(StandaloneStoreError::Json)?;
let object = snapshot.as_object_mut().ok_or_else(|| {
StandaloneStoreError::Json(serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record must be an object",
)))
})?;
object.insert(
"manifest".to_string(),
manifest::write_persisted_worker_manifest_snapshot(&record.manifest)
.map_err(StandaloneStoreError::Json)?,
);
serde_json::to_writer_pretty(writer, &snapshot).map_err(StandaloneStoreError::Json)
}
fn now_unix_ms() -> Result<u64, StandaloneStoreError> { fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
let duration = SystemTime::now() let duration = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -709,7 +751,70 @@ pub enum StandaloneStoreError {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness}; use super::*;
fn test_manifest() -> WorkerManifest {
WorkerManifest::from_toml(
r#"
[worker]
name = "standalone-test"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[engine]
[[scope.allow]]
target = "/tmp"
permission = "write"
"#,
)
.unwrap()
}
#[test]
fn standalone_record_uses_versioned_manifest_adapter_for_legacy_memory() {
let worker_id = "01a05782-d5dd-78f1-b9cd-ce37535bdb9d".parse().unwrap();
let manifest = test_manifest();
let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION,
revision: 6,
worker_id,
worker_name: manifest.worker.name.clone(),
storage_key: "standalone-test".to_string(),
cwd: StandaloneCwdIdentity {
canonical_path: PathBuf::from("/tmp"),
device: None,
inode: None,
},
manifest,
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
active_segment_id: None,
status: StandaloneWorkerStatus::Stopped,
created_at_unix_ms: 1,
updated_at_unix_ms: 2,
shutdown_reason: None,
};
let mut legacy = serde_json::to_value(&record).unwrap();
legacy["manifest"]["feature"]["memory"] = serde_json::json!({
"enabled": false,
"staging": false,
});
let decoded =
decode_worker_record(worker_id, &serde_json::to_vec(&legacy).unwrap()).unwrap();
assert!(!decoded.manifest.feature.memory.profile.enabled);
let mut persisted = Vec::new();
write_worker_record(&mut persisted, &decoded).unwrap();
let persisted: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
assert_eq!(persisted["manifest"]["schema_version"], 2);
assert_eq!(
persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"],
false
);
}
#[test] #[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() { fn lease_liveness_requires_positive_live_or_stale_evidence() {
+13 -3
View File
@@ -99,7 +99,10 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
let mut protocol_client = host.connect(); let mut protocol_client = host.connect();
protocol_client protocol_client
.send(&Method::run_text("read the probe")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"read the probe",
))
.await .await
.expect("submit input"); .expect("submit input");
@@ -336,11 +339,15 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
let worker_id = host.worker_id(); let worker_id = host.worker_id();
let mut protocol_client = host.connect(); let mut protocol_client = host.connect();
protocol_client protocol_client
.send(&Method::run_text("first request")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"first request",
))
.await?; .await?;
wait_for_run_end(&mut protocol_client).await?; wait_for_run_end(&mut protocol_client).await?;
protocol_client protocol_client
.send(&Method::Notify { .send(&Method::Notify {
notification_request_id: protocol::new_submission_request_id(),
message: "persisted notification".to_string(), message: "persisted notification".to_string(),
auto_run: true, auto_run: true,
}) })
@@ -394,7 +401,10 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
assert!(snapshot.contains("persisted notification"), "{snapshot}"); assert!(snapshot.contains("persisted notification"), "{snapshot}");
protocol_client protocol_client
.send(&Method::run_text("continue after restore")) .send(&Method::submit_text(
protocol::new_submission_request_id(),
"continue after restore",
))
.await?; .await?;
wait_for_run_end(&mut protocol_client).await?; wait_for_run_end(&mut protocol_client).await?;
let request = second_inspection let request = second_inspection
+122
View File
@@ -0,0 +1,122 @@
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
CREATE TABLE typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_attributes (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_references (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
kind TEXT NOT NULL,
author TEXT,
at TEXT,
status TEXT,
from_state TEXT,
to_state TEXT,
reason TEXT,
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_labels (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_orchestration_plans (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
related_ticket TEXT,
note TEXT,
accepted_summary TEXT,
accepted_branch TEXT,
accepted_worktree TEXT,
accepted_role_plan TEXT,
author TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, record_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_raw_frontmatter (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_relations (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, kind, target),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_risk_flags (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT, repository_id TEXT, ref_selector TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE "workspace_resource_key_counters" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
PRIMARY KEY (workspace_id, resource_kind)
);
CREATE TABLE "workspace_resource_keys" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
resource_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
resource_key TEXT NOT NULL,
allocated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, resource_kind, resource_id),
UNIQUE (workspace_id, resource_kind, sequence),
UNIQUE (workspace_id, resource_key)
);
CREATE INDEX idx_workspace_resource_keys_reverse
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
CREATE INDEX typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
+2 -6
View File
@@ -26,11 +26,7 @@ pub mod config;
mod sqlite_schema; mod sqlite_schema;
pub mod tool; pub mod tool;
pub use sqlite_schema::{ pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema};
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_resource_key_schema_in_transaction,
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_through,
verify_sqlite_ticket_schema,
};
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"]; const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
@@ -2576,7 +2572,7 @@ impl SqliteTicketBackend {
} }
} }
/// Opens a standalone Ticket backend, applying all Ticket-owned migrations once. /// Opens a standalone Ticket backend at the current canonical schema baseline.
pub fn open(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Result<Self> { pub fn open(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Result<Self> {
let backend = Self::configured(db_path, workspace_id); let backend = Self::configured(db_path, workspace_id);
let connection = backend.connect()?; let connection = backend.connect()?;
+59 -676
View File
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32; const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct Migration { struct Migration {
@@ -16,38 +16,11 @@ struct Migration {
apply: fn(&Connection) -> Result<()>, apply: fn(&Connection) -> Result<()>,
} }
const MIGRATIONS: &[Migration] = &[ const MIGRATIONS: &[Migration] = &[Migration {
Migration { version: LATEST_SQLITE_TICKET_SCHEMA_VERSION,
version: 1, name: "ticket schema baseline",
name: "create_typed_ticket_tables", apply: create_latest_ticket_schema,
apply: create_typed_ticket_tables, }];
},
Migration {
version: 2,
name: "add_ticket_repository_target",
apply: add_ticket_repository_target,
},
Migration {
version: 3,
name: "convert_legacy_reviews_to_comments",
apply: retire_legacy_ticket_review_events,
},
Migration {
version: 4,
name: "add_ticket_query_indexes",
apply: add_ticket_query_indexes,
},
Migration {
version: 5,
name: "add_workspace_human_keys",
apply: add_workspace_human_keys,
},
Migration {
version: 6,
name: "rename_workspace_resource_keys",
apply: rename_workspace_resource_keys,
},
];
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct ExpectedColumn { struct ExpectedColumn {
@@ -258,30 +231,12 @@ const fn column(
} }
} }
/// Applies the Ticket crate's SQLite migrations and verifies the resulting schema. /// Creates and verifies the Ticket crate's latest SQLite schema.
/// ///
/// This is a startup/standalone-open operation. Normal Ticket request handling must /// This is a startup/standalone-open operation. Normal Ticket request handling must
/// use [`verify_sqlite_ticket_schema`] instead, so request paths never acquire DDL /// use [`verify_sqlite_ticket_schema`] instead, so request paths never acquire DDL
/// authority. /// authority.
pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
migrate_sqlite_ticket_schema_through(connection, LATEST_SQLITE_TICKET_SCHEMA_VERSION)
}
/// Applies Ticket migrations only through `target_version`.
///
/// This exists for the Workspace Server's ordered migration bridge: older Server
/// migrations must materialize the Ticket schema shape they were written against
/// before the current Ticket migration is applied at the matching Server version.
#[doc(hidden)]
pub fn migrate_sqlite_ticket_schema_through(
connection: &Connection,
target_version: i64,
) -> Result<()> {
if !(1..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&target_version) {
return Err(TicketError::Sqlite(format!(
"unsupported Ticket schema migration target {target_version}"
)));
}
connection connection
.busy_timeout(Duration::from_secs(5)) .busy_timeout(Duration::from_secs(5))
.map_err(sqlite_err)?; .map_err(sqlite_err)?;
@@ -302,25 +257,10 @@ pub fn migrate_sqlite_ticket_schema_through(
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?; verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
let applied = load_applied_migrations(connection)?; let applied = load_applied_migrations(connection)?;
validate_applied_migrations(&applied)?; if applied.is_empty() {
let migration = MIGRATIONS
if let Some(version) = applied .first()
.keys() .ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?;
.copied()
.find(|version| *version > target_version)
{
return Err(TicketError::Sqlite(format!(
"Ticket schema version {version} is newer than requested migration target {target_version}"
)));
}
for migration in MIGRATIONS
.iter()
.filter(|migration| migration.version <= target_version)
{
if applied.contains_key(&migration.version) {
continue;
}
(migration.apply)(connection)?; (migration.apply)(connection)?;
connection connection
.execute( .execute(
@@ -333,24 +273,11 @@ pub fn migrate_sqlite_ticket_schema_through(
], ],
) )
.map_err(sqlite_err)?; .map_err(sqlite_err)?;
} else {
validate_applied_migrations(&applied)?;
} }
if target_version == LATEST_SQLITE_TICKET_SCHEMA_VERSION { verify_sqlite_ticket_schema(connection)
verify_sqlite_ticket_schema(connection)
} else {
let applied = load_applied_migrations(connection)?;
let expected = MIGRATIONS
.iter()
.filter(|migration| migration.version <= target_version)
.map(|migration| (migration.version, migration.name.to_string()))
.collect::<BTreeMap<_, _>>();
if applied != expected {
return Err(TicketError::Sqlite(format!(
"Ticket schema migration history does not match target version {target_version}"
)));
}
Ok(())
}
})(); })();
match result { match result {
@@ -362,47 +289,6 @@ pub fn migrate_sqlite_ticket_schema_through(
} }
} }
/// Applies the resource-key Ticket migration inside a transaction owned by the
/// Workspace Server. The caller must provide an active transaction; this function
/// deliberately does not begin or commit one so the Ticket and Server migration
/// markers can be persisted atomically.
#[doc(hidden)]
pub fn migrate_sqlite_ticket_resource_key_schema_in_transaction(
connection: &Connection,
) -> Result<()> {
connection
.execute_batch(
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);",
)
.map_err(sqlite_err)?;
let applied = load_applied_migrations(connection)?;
validate_applied_migrations(&applied)?;
if applied.contains_key(&LATEST_SQLITE_TICKET_SCHEMA_VERSION) {
return verify_sqlite_ticket_schema(connection);
}
let expected_previous = LATEST_SQLITE_TICKET_SCHEMA_VERSION - 1;
if applied.len() != expected_previous as usize || !applied.contains_key(&expected_previous) {
return Err(TicketError::Sqlite(format!(
"Ticket schema must be at version {expected_previous} before the resource-key migration"
)));
}
let migration = MIGRATIONS
.last()
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".to_string()))?;
(migration.apply)(connection)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations (version, name, applied_at) VALUES (?1, ?2, datetime('now'))",
params![migration.version, migration.name],
)
.map_err(sqlite_err)?;
verify_sqlite_ticket_schema(connection)
}
/// Verifies the current Ticket-owned SQLite schema without executing DDL. /// Verifies the current Ticket-owned SQLite schema without executing DDL.
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
@@ -539,238 +425,9 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
} }
} }
fn create_typed_ticket_tables(connection: &Connection) -> Result<()> { fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
connection connection
.execute_batch( .execute_batch(include_str!("latest_schema.sql"))
r#"
CREATE TABLE IF NOT EXISTS typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE IF NOT EXISTS typed_ticket_labels (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_risk_flags (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_raw_frontmatter (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
kind TEXT NOT NULL,
author TEXT,
at TEXT,
status TEXT,
from_state TEXT,
to_state TEXT,
reason TEXT,
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_event_references (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_event_attributes (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_relations (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, kind, target),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_orchestration_plans (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
related_ticket TEXT,
note TEXT,
accepted_summary TEXT,
accepted_branch TEXT,
accepted_worktree TEXT,
accepted_role_plan TEXT,
author TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, record_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
"#,
)
.map_err(sqlite_err)
}
fn add_ticket_repository_target(connection: &Connection) -> Result<()> {
add_column_if_missing(connection, "typed_tickets", "repository_id", "TEXT")?;
add_column_if_missing(connection, "typed_tickets", "ref_selector", "TEXT")
}
fn retire_legacy_ticket_review_events(connection: &Connection) -> Result<()> {
// Historical prose remains visible for audit, but it is explicitly converted to a
// non-authoritative comment. Approval authority now lives only in Merge Requests.
connection
.execute_batch(
r#"
INSERT OR REPLACE INTO typed_ticket_event_attributes
(workspace_id, ticket_id, event_index, key, value)
SELECT workspace_id, ticket_id, event_index, 'legacy_event_kind', 'review'
FROM typed_ticket_events WHERE kind = 'review';
UPDATE typed_ticket_events
SET kind = 'comment', status = NULL, heading = 'Legacy review (non-authoritative)'
WHERE kind = 'review';
DELETE FROM typed_ticket_event_attributes
WHERE key IN ('result', 'review_result', 'status')
AND EXISTS (
SELECT 1 FROM typed_ticket_events event
WHERE event.workspace_id = typed_ticket_event_attributes.workspace_id
AND event.ticket_id = typed_ticket_event_attributes.ticket_id
AND event.event_index = typed_ticket_event_attributes.event_index
AND event.heading = 'Legacy review (non-authoritative)'
);
"#,
)
.map_err(sqlite_err)
}
fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX IF NOT EXISTS typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
"#,
)
.map_err(sqlite_err)
}
fn add_workspace_human_keys(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS workspace_resource_human_keys (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
resource_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
human_key TEXT NOT NULL,
allocated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, resource_kind, resource_id),
UNIQUE (workspace_id, resource_kind, sequence),
UNIQUE (workspace_id, human_key)
);
CREATE TABLE IF NOT EXISTS workspace_resource_human_key_counters (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
PRIMARY KEY (workspace_id, resource_kind)
);
INSERT OR IGNORE INTO workspace_resource_human_keys (
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
)
SELECT workspace_id,
'ticket',
ticket_id,
ROW_NUMBER() OVER (
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
),
'T-' || ROW_NUMBER() OVER (
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
),
COALESCE(created_at, updated_at)
FROM typed_tickets;
INSERT INTO workspace_resource_human_key_counters (
workspace_id, resource_kind, next_sequence
)
SELECT workspace_id, 'ticket', MAX(sequence) + 1
FROM workspace_resource_human_keys
WHERE resource_kind = 'ticket'
GROUP BY workspace_id
ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET
next_sequence = MAX(next_sequence, excluded.next_sequence);
"#,
)
.map_err(sqlite_err)
}
fn rename_workspace_resource_keys(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
ALTER TABLE workspace_resource_human_keys RENAME TO workspace_resource_keys;
ALTER TABLE workspace_resource_keys RENAME COLUMN human_key TO resource_key;
ALTER TABLE workspace_resource_human_key_counters RENAME TO workspace_resource_key_counters;
DROP INDEX IF EXISTS idx_workspace_resource_human_keys_reverse;
CREATE INDEX idx_workspace_resource_keys_reverse
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
"#,
)
.map_err(sqlite_err)
}
fn add_column_if_missing(
connection: &Connection,
table: &str,
column: &str,
declaration: &str,
) -> Result<()> {
let columns = load_columns(connection, table)?;
if columns.iter().any(|found| found.name == column) {
return Ok(());
}
connection
.execute_batch(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {declaration}"
))
.map_err(sqlite_err) .map_err(sqlite_err)
} }
@@ -796,33 +453,17 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
} }
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> { fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> {
for (&version, name) in applied { let expected = BTreeMap::from([(
let Some(expected) = MIGRATIONS LATEST_SQLITE_TICKET_SCHEMA_VERSION,
.iter() MIGRATIONS[0].name.to_string(),
.find(|migration| migration.version == version) )]);
else { if applied == &expected {
return Err(TicketError::Sqlite(format!( Ok(())
"unsupported Ticket schema migration version {version}; latest supported version is {LATEST_SQLITE_TICKET_SCHEMA_VERSION}" } else {
))); Err(TicketError::Sqlite(format!(
}; "Ticket schema migration history must contain only the canonical version {LATEST_SQLITE_TICKET_SCHEMA_VERSION} baseline marker"
if name != expected.name { )))
return Err(TicketError::Sqlite(format!(
"Ticket schema migration {version} is named {name:?}, expected {:?}",
expected.name
)));
}
} }
for migration in MIGRATIONS {
if applied.keys().any(|version| *version > migration.version)
&& !applied.contains_key(&migration.version)
{
return Err(TicketError::Sqlite(format!(
"Ticket schema migration history has a gap at version {}",
migration.version
)));
}
}
Ok(())
} }
#[derive(Debug)] #[derive(Debug)]
@@ -1189,223 +830,16 @@ mod tests {
verify_sqlite_ticket_schema(&connection).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap();
let versions = load_applied_migrations(&connection).unwrap(); let versions = load_applied_migrations(&connection).unwrap();
assert_eq!(versions.len(), 6);
assert_eq!( assert_eq!(
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION), versions,
Some(&"rename_workspace_resource_keys".to_string()) BTreeMap::from([(
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
"ticket schema baseline".to_string(),
)])
); );
}
#[test]
fn adopts_existing_current_schema_without_losing_data() {
let connection = Connection::open_in_memory().unwrap();
create_typed_ticket_tables(&connection).unwrap();
add_ticket_repository_target(&connection).unwrap();
connection
.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit, repository_id, ref_selector
) VALUES ('workspace-1', 'ticket-1', 'ticket-1', 'kept', 'open',
'task', 'medium', 'body', 'ready', 1, 'main', 'develop')",
[],
)
.unwrap();
connection
.execute_batch(
"INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at, heading, body
) VALUES (
'workspace-1', 'ticket-1', 0, 'comment', 'hare',
'2026-08-10T00:00:00Z', 'Evidence', 'event kept'
);
INSERT INTO typed_ticket_event_references (
workspace_id, ticket_id, event_index, ordinal, kind, target
) VALUES ('workspace-1', 'ticket-1', 0, 0, 'commit', 'abc123');
INSERT INTO typed_ticket_relations (
workspace_id, ticket_id, kind, target, note, author, at
) VALUES (
'workspace-1', 'ticket-1', 'related', 'ticket-2', 'relation kept',
'hare', '2026-08-10T00:00:00Z'
);
INSERT INTO typed_ticket_orchestration_plans (
workspace_id, ticket_id, record_id, kind, note, author, at
) VALUES (
'workspace-1', 'ticket-1', 'plan-1', 'waiting_capacity_note',
'plan kept', 'hare', '2026-08-10T00:00:00Z'
);
INSERT INTO typed_ticket_artifacts (
workspace_id, ticket_id, relative_path, content
) VALUES ('workspace-1', 'ticket-1', 'evidence.txt', X'6b657074');",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap(); migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(load_applied_migrations(&connection).unwrap(), versions);
let row = connection
.query_row(
"SELECT title, repository_id, ref_selector FROM typed_tickets",
[],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
},
)
.unwrap();
assert_eq!(row, ("kept".into(), "main".into(), "develop".into()));
let preserved = connection
.query_row(
"SELECT
(SELECT COUNT(*) FROM typed_ticket_events),
(SELECT COUNT(*) FROM typed_ticket_event_references),
(SELECT COUNT(*) FROM typed_ticket_relations),
(SELECT COUNT(*) FROM typed_ticket_orchestration_plans),
(SELECT COUNT(*) FROM typed_ticket_artifacts)",
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
))
},
)
.unwrap();
assert_eq!(preserved, (1, 1, 1, 1, 1));
}
#[test]
fn v5_backfills_ticket_keys_and_v6_preserves_them_under_resource_key_schema() {
let connection = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema_through(&connection, 4).unwrap();
connection.execute_batch(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit, created_at, updated_at
) VALUES
('workspace-1', 'later', 'later', 'Later', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z'),
('workspace-1', 'earlier', 'earlier', 'Earlier', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');"
).unwrap();
migrate_sqlite_ticket_schema_through(&connection, 5).unwrap();
let legacy_keys = connection
.prepare(
"SELECT resource_id, human_key FROM workspace_resource_human_keys
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
ORDER BY sequence",
)
.unwrap()
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
legacy_keys,
vec![
("earlier".into(), "T-1".into()),
("later".into(), "T-2".into())
]
);
let next: i64 = connection
.query_row(
"SELECT next_sequence FROM workspace_resource_human_key_counters
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(next, 3);
migrate_sqlite_ticket_schema(&connection).unwrap();
let resource_keys = connection
.prepare(
"SELECT resource_id, resource_key FROM workspace_resource_keys
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
ORDER BY sequence",
)
.unwrap()
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert_eq!(resource_keys, legacy_keys);
assert_eq!(
connection
.query_row(
"SELECT next_sequence FROM workspace_resource_key_counters
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
3
);
for legacy_table in [
"workspace_resource_human_keys",
"workspace_resource_human_key_counters",
] {
assert!(
connection
.query_row(
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
[legacy_table],
|_| Ok(()),
)
.optional()
.unwrap()
.is_none(),
"{legacy_table} still exists"
);
}
}
#[test]
fn upgrades_legacy_schema_without_repository_target_columns() {
let connection = Connection::open_in_memory().unwrap();
create_typed_ticket_tables(&connection).unwrap();
connection
.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit
) VALUES ('workspace-1', 'ticket-1', 'ticket-1', 'legacy', 'open',
'task', 'medium', 'body', 'ready', 1)",
[],
)
.unwrap();
connection
.execute_batch(
"CREATE TABLE ticket_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (1, 'create_typed_ticket_tables', '2026-08-10T00:00:00Z');",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
let columns = load_columns(&connection, "typed_tickets").unwrap();
assert!(columns.iter().any(|column| column.name == "repository_id"));
assert!(columns.iter().any(|column| column.name == "ref_selector"));
let title = connection
.query_row("SELECT title FROM typed_tickets", [], |row| {
row.get::<_, String>(0)
})
.unwrap();
assert_eq!(title, "legacy");
} }
#[test] #[test]
@@ -1421,12 +855,32 @@ mod tests {
.unwrap(); .unwrap();
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!( assert!(error.to_string().contains(
error "migration history must contain only the canonical version 6 baseline marker"
.to_string() ));
.contains("unsupported Ticket schema migration version 99") assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
); }
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7);
#[test]
fn rejects_legacy_migration_marker() {
let connection = Connection::open_in_memory().unwrap();
connection
.execute_batch(
"CREATE TABLE ticket_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (6, 'rename_workspace_resource_keys', '2026-08-10T00:00:00Z');",
)
.unwrap();
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 6 baseline marker"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
} }
#[test] #[test]
@@ -1509,77 +963,6 @@ mod tests {
verify_sqlite_ticket_schema(&connection).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap();
} }
#[test]
fn migration_rejects_constraint_drift_and_rolls_back_version_adoption() {
let connection = Connection::open_in_memory().unwrap();
connection
.execute_batch(
"CREATE TABLE typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT,
PRIMARY KEY (ticket_id, workspace_id)
);",
)
.unwrap();
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains("primary-key position"));
let migration_table_exists = connection
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ticket_schema_migrations'",
[],
|_| Ok(()),
)
.optional()
.unwrap()
.is_some();
assert!(!migration_table_exists);
}
#[test]
fn legacy_review_upgrade_preserves_prose_as_non_authoritative_comment() {
let connection = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
connection.execute("INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES ('workspace-1','ticket-1','ticket-1','title','open','task','medium','body','inprogress',1)",[]).unwrap();
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
connection
.execute_batch(
"DROP TABLE workspace_resource_key_counters;
DROP TABLE workspace_resource_keys;
DELETE FROM ticket_schema_migrations WHERE version >= 3;",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
assert_eq!(kind, "comment");
assert_eq!(status, None);
assert_eq!(
heading.as_deref(),
Some("Legacy review (non-authoritative)")
);
assert_eq!(body.as_deref(), Some("legacy evidence"));
let attributes:i64=connection.query_row("SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1'",[],|row|row.get(0)).unwrap();
assert_eq!(attributes, 1);
let legacy:String=connection.query_row("SELECT value FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND key='legacy_event_kind'",[],|row|row.get(0)).unwrap();
assert_eq!(legacy, "review");
}
#[test] #[test]
fn concurrent_migrators_converge_on_one_version_history() { fn concurrent_migrators_converge_on_one_version_history() {
let directory = tempdir().unwrap(); let directory = tempdir().unwrap();
@@ -1602,6 +985,6 @@ mod tests {
let connection = Connection::open(database).unwrap(); let connection = Connection::open(database).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 1);
} }
} }
+3
View File
@@ -118,6 +118,7 @@ impl Tool for BashTool {
command: params.command, command: params.command,
timeout_secs, timeout_secs,
output_limit: INLINE_BYTE_BUDGET, output_limit: INLINE_BYTE_BUDGET,
cwd: None,
spill_dir: Some(self.output_dir.clone()), spill_dir: Some(self.output_dir.clone()),
tool_call_id: Some(call_id.clone()), tool_call_id: Some(call_id.clone()),
}) })
@@ -299,11 +300,13 @@ mod tests {
target: root.path().to_path_buf(), target: root.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: output.path().to_path_buf(), target: output.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
+7 -2
View File
@@ -298,7 +298,12 @@ mod tests {
.execute(&inp.to_string(), Default::default()) .execute(&inp.to_string(), Default::default())
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err}"); match err {
assert!(msg.contains("modified externally"), "{msg}"); ToolError::ExecutionFailed(message) => assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying: a.txt"
),
other => panic!("expected execution failure, got {other:?}"),
}
} }
} }
+47 -1
View File
@@ -43,7 +43,8 @@ impl From<ToolsError> for ToolError {
| workdir::WorkdirError::Io { .. } | workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed | workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_), | workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_),
) => ToolError::ExecutionFailed(err.to_string()), ) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::FileSystem(_) ToolsError::FileSystem(_)
| ToolsError::WorkdirSession(_) | ToolsError::WorkdirSession(_)
@@ -55,3 +56,48 @@ impl From<ToolsError> for ToolError {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode};
#[test]
fn local_workdir_content_conflict_is_retryable_execution_failure() {
let error = ToolError::from(ToolsError::WorkdirSession(
fs_operation::FsError::Conflict("src/main.rs".to_string()).into(),
));
match error {
ToolError::ExecutionFailed(message) => assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying: src/main.rs"
),
other => panic!("expected execution failure, got {other:?}"),
}
}
#[test]
fn remote_workdir_content_conflict_is_retryable_without_host_path() {
let transport = WorkdirTransportError::from_workdir_error(
&workdir::WorkdirError::Conflict("/runtime/private/checkout/src/main.rs".to_string()),
);
assert_eq!(transport.code, WorkdirTransportErrorCode::Conflict);
assert_eq!(
transport.message,
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
let error = ToolError::from(ToolsError::WorkdirSession(transport.into_workdir_error()));
match error {
ToolError::ExecutionFailed(message) => {
assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
assert!(!message.contains("/runtime/private"));
}
other => panic!("expected execution failure, got {other:?}"),
}
}
}
+7 -2
View File
@@ -219,8 +219,13 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err}"); match err {
assert!(msg.contains("modified externally"), "{msg}"); ToolError::ExecutionFailed(message) => assert_eq!(
message,
"The target file's content or existence changed since it was last observed; read the file again before retrying: a.txt"
),
other => panic!("expected execution failure, got {other:?}"),
}
} }
#[tokio::test] #[tokio::test]
+1
View File
@@ -40,6 +40,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
let scope = Scope::from_config(&config).unwrap(); let scope = Scope::from_config(&config).unwrap();
let fs: WorkdirSessionHandle = let fs: WorkdirSessionHandle =
+1
View File
@@ -27,6 +27,7 @@ fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
target: spill.to_path_buf(), target: spill.to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}); });
Scope::from_config(&config).unwrap() Scope::from_config(&config).unwrap()
} }
+373 -198
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use protocol::{ use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method, InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
RewindTarget, RunResult, Segment, WorkerStatus, RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
}; };
use crate::block::{ use crate::block::{
@@ -102,23 +102,6 @@ struct RollbackSubmitState {
turn_before: usize, turn_before: usize,
} }
#[derive(Clone)]
pub struct QueuedInput {
segments: Vec<Segment>,
preview: String,
}
impl QueuedInput {
fn new(segments: Vec<Segment>) -> Self {
let preview = Segment::flatten_to_text(&segments);
Self { segments, preview }
}
pub fn preview(&self) -> &str {
&self.preview
}
}
struct ComposerInputHistory { struct ComposerInputHistory {
entries: VecDeque<Vec<Segment>>, entries: VecDeque<Vec<Segment>>,
browse: Option<ComposerInputHistoryBrowse>, browse: Option<ComposerInputHistoryBrowse>,
@@ -242,8 +225,10 @@ pub struct WorkerViewTab {
pub struct App { pub struct App {
pub worker_name: String, pub worker_name: String,
pub connected: bool, pub connected: bool,
/// Last controller status reported by the Worker. Drives the status line /// Latest authoritative revisioned live execution state.
/// and Ctrl-key routing; do not infer this solely from replayed history. pub worker_state: WorkerStateSnapshot,
next_command_id: u64,
/// Derived Runtime-catalog compatibility projection used by existing UI.
pub worker_status: WorkerStatus, pub worker_status: WorkerStatus,
/// True while the Worker is in `WorkerStatus::Running`. /// True while the Worker is in `WorkerStatus::Running`.
pub running: bool, pub running: bool,
@@ -272,7 +257,7 @@ pub struct App {
/// Current transient actionbar notice. Notices are local UI state only: /// Current transient actionbar notice. Notices are local UI state only:
/// they are never appended to transcript/session history or LLM context. /// they are never appended to transcript/session history or LLM context.
actionbar_notice: Option<ActionbarNotice>, actionbar_notice: Option<ActionbarNotice>,
/// Normal composer input that is submitted as `Method::Run`. /// Normal composer input that is submitted as `Method::Submit`.
pub input: InputBuffer, pub input: InputBuffer,
/// Separate command-line input. It is never submitted as a user message. /// Separate command-line input. It is never submitted as a user message.
pub command_input: InputBuffer, pub command_input: InputBuffer,
@@ -292,6 +277,9 @@ pub struct App {
/// Turn/protocol errors retained when a real `SegmentStart` replaces the /// Turn/protocol errors retained when a real `SegmentStart` replaces the
/// replayable conversation rows during segment rotation. /// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>, run_error_messages: Vec<String>,
/// Current compaction identity/revision used to fence snapshot/live updates.
active_compaction: Option<(String, u64)>,
pub compaction_progress: Option<protocol::InFlightCompaction>,
/// Presentation-only Internal Worker projections keyed by session identity. /// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate selectable views and never mixed into `blocks`. /// They are rendered in separate selectable views and never mixed into `blocks`.
pub internal_workers: Vec<InternalWorkerView>, pub internal_workers: Vec<InternalWorkerView>,
@@ -333,9 +321,8 @@ pub struct App {
/// Top entry index of the task pane's visible window. Clamped on /// Top entry index of the task pane's visible window. Clamped on
/// render so it never points past the end of the list. /// render so it never points past the end of the list.
pub task_pane_scroll: usize, pub task_pane_scroll: usize,
/// TUI-local FIFO of user inputs submitted while the Worker is already running. /// Authoritative WorkerSession FIFO summary received from snapshot/live events.
/// Entries have not been sent to the Worker yet, so they remain editable/cancellable locally. pending_submissions: protocol::PendingSubmissionsSnapshot,
queued_inputs: VecDeque<QueuedInput>,
/// TUI-local readline-style composer input history. This is intentionally /// TUI-local readline-style composer input history. This is intentionally
/// client-side only: recalled entries are plain drafts until submitted again. /// client-side only: recalled entries are plain drafts until submitted again.
input_history: ComposerInputHistory, input_history: ComposerInputHistory,
@@ -355,6 +342,8 @@ impl App {
Self { Self {
worker_name, worker_name,
connected: false, connected: false,
worker_state: WorkerStateSnapshot::initial(),
next_command_id: 1,
worker_status: WorkerStatus::Idle, worker_status: WorkerStatus::Idle,
running: false, running: false,
paused: false, paused: false,
@@ -378,6 +367,8 @@ impl App {
quit_confirm: None, quit_confirm: None,
shutdown_confirm: None, shutdown_confirm: None,
blocks: Vec::new(), blocks: Vec::new(),
active_compaction: None,
compaction_progress: None,
run_error_messages: Vec::new(), run_error_messages: Vec::new(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
selected_internal_worker_session_id: None, selected_internal_worker_session_id: None,
@@ -395,7 +386,7 @@ impl App {
text_selection: TextSelectionState::default(), text_selection: TextSelectionState::default(),
task_pane_open: false, task_pane_open: false,
task_pane_scroll: 0, task_pane_scroll: 0,
queued_inputs: VecDeque::new(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
input_history: ComposerInputHistory::new(), input_history: ComposerInputHistory::new(),
input_history_store: None, input_history_store: None,
pending_submit_rollback: None, pending_submit_rollback: None,
@@ -763,23 +754,40 @@ impl App {
if self.paused { if self.paused {
self.input_history.cancel_browse(); self.input_history.cancel_browse();
self.input.clear(); self.input.clear();
return Some(Method::Resume); let command = self.next_command_envelope();
return Some(Method::Resume { command });
} }
return None; return None;
} }
self.record_input_history(segments.clone()); self.record_input_history(segments.clone());
if self.running {
self.queued_inputs.push_back(QueuedInput::new(segments));
self.input.clear();
self.completion = None;
return None;
}
self.input.clear(); self.input.clear();
Some(self.method_for_run(segments)) Some(self.method_for_run(segments))
} }
pub fn submit_notify_input(&mut self) -> Option<Method> {
let segments = self.input.submit_segments();
if segments_are_blank(&segments) {
return None;
}
if segments
.iter()
.any(|segment| matches!(segment, Segment::UploadedFile { .. }))
{
self.push_error("Notify accepts text only; remove attachments or queue a Submit.");
return None;
}
let message = Segment::flatten_to_text(&segments);
self.record_input_history(segments);
self.input.clear();
Some(Method::Notify {
notification_request_id: protocol::new_submission_request_id(),
message,
auto_run: true,
})
}
pub fn restore_unsent_run(&mut self, method: &Method) { pub fn restore_unsent_run(&mut self, method: &Method) {
let Method::Run { input } = method else { let Method::Submit { input, .. } = method else {
return; return;
}; };
self.pending_submit_rollback = None; self.pending_submit_rollback = None;
@@ -787,8 +795,9 @@ impl App {
self.input.replace_with_segments(input); self.input.replace_with_segments(input);
self.completion = None; self.completion = None;
} else { } else {
self.queued_inputs self.push_error(
.push_front(QueuedInput::new(input.clone())); "Submit transport failed; current Composer was preserved and the unsent input was not queued.",
);
} }
} }
@@ -804,7 +813,10 @@ impl App {
block_start: self.blocks.len(), block_start: self.blocks.len(),
turn_before: self.turn_index, turn_before: self.turn_index,
}); });
Method::Run { input: segments } Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: segments,
}
} }
fn record_input_history(&mut self, segments: Vec<Segment>) { fn record_input_history(&mut self, segments: Vec<Segment>) {
@@ -825,7 +837,7 @@ impl App {
} }
pub fn queued_input_count(&self) -> usize { pub fn queued_input_count(&self) -> usize {
self.queued_inputs.len() self.pending_submissions.submissions.len()
} }
#[cfg(test)] #[cfg(test)]
@@ -910,36 +922,31 @@ impl App {
} }
} }
pub fn continue_pending_method(&self) -> Option<Method> {
Some(Method::ContinuePending {
expected_revision: self.pending_submissions.revision,
expected_head_id: self.pending_submissions.head_id.clone()?,
})
}
pub fn clear_pending_method(&self) -> Method {
Method::ClearPendingSubmissions {
expected_revision: self.pending_submissions.revision,
}
}
pub fn cancel_pending_method(&self, submission_id: String) -> Method {
Method::CancelPendingSubmission {
submission_id,
expected_revision: self.pending_submissions.revision,
}
}
pub fn next_queued_input_preview(&self) -> Option<&str> { pub fn next_queued_input_preview(&self) -> Option<&str> {
self.queued_inputs.front().map(QueuedInput::preview) self.pending_submissions
} .submissions
.first()
pub fn clear_queued_inputs(&mut self) -> usize { .map(|submission| submission.submission_id.as_str())
let cleared = self.queued_inputs.len();
self.queued_inputs.clear();
cleared
}
pub fn restore_next_queued_input_to_composer(&mut self) -> bool {
if self.queued_inputs.is_empty() {
return false;
}
if !self.input.is_empty() {
self.push_error("Composer is not empty; clear it before editing queued input.");
return false;
}
let Some(queued) = self.queued_inputs.pop_front() else {
return false;
};
self.input_history.cancel_browse();
self.input.replace_with_segments(&queued.segments);
self.completion = None;
true
}
fn pop_next_queued_run(&mut self) -> Option<Method> {
let queued = self.queued_inputs.pop_front()?;
Some(self.method_for_run(queued.segments))
} }
pub fn clear_actionbar_notice(&mut self) { pub fn clear_actionbar_notice(&mut self) {
@@ -1117,12 +1124,31 @@ impl App {
} }
} }
pub fn next_command_envelope(&mut self) -> WorkerCommandEnvelope {
let command_id = self
.next_command_id
.max(self.worker_state.last_command_id.saturating_add(1));
let command = WorkerCommandEnvelope::new(command_id);
self.next_command_id = command_id.saturating_add(1);
command
}
fn apply_worker_state_snapshot(&mut self, snapshot: &WorkerStateSnapshot) {
self.worker_state = snapshot.clone();
self.set_worker_status(self.worker_state.catalog_status());
}
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> { pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) { if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
return None; return None;
} }
match event { match event {
Event::SubmissionAccepted { .. } => {}
Event::SubmissionRejected { message, .. } => self.push_error(message),
Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = pending;
}
Event::UserMessage { segments } => { Event::UserMessage { segments } => {
self.turn_index += 1; self.turn_index += 1;
self.blocks.push(Block::TurnHeader { self.blocks.push(Block::TurnHeader {
@@ -1148,18 +1174,14 @@ impl App {
self.assistant_streaming = false; self.assistant_streaming = false;
} }
Event::TurnStart { .. } => { Event::TurnStart { .. } => {
self.set_worker_status(WorkerStatus::Running);
self.run_requests += 1; self.run_requests += 1;
self.current_tool = None; self.current_tool = None;
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
self.assistant_streaming = false; self.assistant_streaming = false;
} }
Event::InvokeStart { .. } => { Event::InvokeStart { .. } => {}
self.set_worker_status(WorkerStatus::Running);
}
// UI consumers of per-attempt LlmCall semantics remain out of scope; // UI consumers of per-attempt LlmCall semantics remain out of scope;
// the run-level status starts at InvokeStart and TurnStart counts each // authoritative run state comes only from WorkerStateSnapshot.
// LLM request within that run.
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => { Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
} }
@@ -1366,25 +1388,53 @@ impl App {
output_tokens: self.run_output_tokens, output_tokens: self.run_output_tokens,
}); });
self.pending_submit_rollback = None; self.pending_submit_rollback = None;
self.reset_run_state(match result { self.reset_run_state();
RunResult::Paused => WorkerStatus::Paused, }
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => { }
WorkerStatus::Idle Event::CompactionProgress { compaction } => {
} self.compaction_progress = compaction.filter(|progress| {
}); matches!(
if matches!(result, RunResult::Finished | RunResult::LimitReached) { (&self.worker_state.state, progress.trigger),
return self.pop_next_queued_run(); (
protocol::WorkerState::Busy(protocol::WorkerBusyState::Maintenance(
protocol::WorkerMaintenanceState::Compacting
)),
protocol::CompactionTrigger::Manual
) | (
protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(_)),
protocol::CompactionTrigger::PreRun
| protocol::CompactionTrigger::RequestThreshold
)
)
});
}
Event::CompactStart { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
if should_apply {
self.active_compaction = Some((lifecycle.compaction_id, lifecycle.revision));
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
} }
} }
} }
Event::CompactStart { .. } => {
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
}
}
Event::CompactDone { lifecycle } => { Event::CompactDone { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
if !should_apply {
return None;
}
self.active_compaction = None;
self.session_context_tokens = 0; self.session_context_tokens = 0;
let new_segment_id = lifecycle let new_segment_id = lifecycle
.new_segment_id .new_segment_id
@@ -1410,6 +1460,16 @@ impl App {
} }
} }
Event::CompactFailed { lifecycle } => { Event::CompactFailed { lifecycle } => {
let should_apply = match &self.active_compaction {
None => true,
Some((id, revision)) => {
id == &lifecycle.compaction_id && lifecycle.revision > *revision
}
};
if !should_apply {
return None;
}
self.active_compaction = None;
let error = lifecycle let error = lifecycle
.error .error
.unwrap_or_else(|| "compaction failed".to_string()); .unwrap_or_else(|| "compaction failed".to_string());
@@ -1444,14 +1504,15 @@ impl App {
Event::Snapshot { Event::Snapshot {
session, session,
greeting, greeting,
status, state,
in_flight, in_flight,
internal_workers, internal_workers,
} => { } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.pending_submissions = session.pending_submissions.clone();
self.apply_worker_state_snapshot(&state);
self.restore_snapshot(&session, greeting, in_flight); self.restore_snapshot(&session, greeting, in_flight);
self.replace_internal_worker_snapshots(internal_workers); self.replace_internal_worker_snapshots(internal_workers);
self.set_worker_status(status);
} }
Event::InternalWorker { Event::InternalWorker {
worker, worker,
@@ -1461,9 +1522,22 @@ impl App {
Event::InternalWorkerRemoved { worker, revision } => { Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision) self.remove_internal_worker(worker, revision)
} }
Event::Status { status } => { Event::WorkerState { snapshot } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.set_worker_status(status); self.apply_worker_state_snapshot(&snapshot);
if let Some(progress) = self.compaction_progress.take() {
let _ = self.handle_worker_event(Event::CompactionProgress {
compaction: Some(progress),
});
}
}
Event::CommandAcknowledged { acknowledgement } => {
self.apply_worker_state_snapshot(&acknowledgement.state);
if let Some(progress) = self.compaction_progress.take() {
let _ = self.handle_worker_event(Event::CompactionProgress {
compaction: Some(progress),
});
}
} }
// Command telemetry is an operational Web Console surface. The // Command telemetry is an operational Web Console surface. The
// TUI continues to render the final Bash ToolResult from history. // TUI continues to render the final Bash ToolResult from history.
@@ -1503,7 +1577,7 @@ impl App {
}; };
self.completion = None; self.completion = None;
self.close_rewind_picker(); self.close_rewind_picker();
self.reset_run_state(self.worker_status); self.reset_run_state();
let mut message = if restored_composer { let mut message = if restored_composer {
format!( format!(
"Rewound session: discarded {} log entries; restored selected input to composer.", "Rewound session: discarded {} log entries; restored selected input to composer.",
@@ -1551,8 +1625,7 @@ impl App {
None None
} }
fn reset_run_state(&mut self, status: WorkerStatus) { fn reset_run_state(&mut self) {
self.set_worker_status(status);
self.run_requests = 0; self.run_requests = 0;
self.run_upload_tokens = 0; self.run_upload_tokens = 0;
self.run_output_tokens = 0; self.run_output_tokens = 0;
@@ -1582,7 +1655,7 @@ impl App {
"Rolled back empty assistant turn; no local submitted input was available to restore." "Rolled back empty assistant turn; no local submitted input was available to restore."
.to_owned() .to_owned()
}; };
self.reset_run_state(WorkerStatus::Idle); self.reset_run_state();
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Warn, level: AlertLevel::Warn,
source: AlertSource::Worker, source: AlertSource::Worker,
@@ -1591,6 +1664,7 @@ impl App {
} }
fn apply_in_flight_snapshot(&mut self, snapshot: InFlightSnapshot) { fn apply_in_flight_snapshot(&mut self, snapshot: InFlightSnapshot) {
let compaction = snapshot.compaction;
for block in snapshot.blocks { for block in snapshot.blocks {
match block { match block {
InFlightBlock::Text { text, finished } => { InFlightBlock::Text { text, finished } => {
@@ -1632,6 +1706,8 @@ impl App {
} }
} }
} }
self.active_compaction = None;
let _ = self.handle_worker_event(Event::CompactionProgress { compaction });
} }
fn append_assistant_text(&mut self, text: &str) { fn append_assistant_text(&mut self, text: &str) {
@@ -2026,12 +2102,18 @@ impl App {
self.input_mode = CommandInputMode::Composer; self.input_mode = CommandInputMode::Composer;
self.command_completion_selected = None; self.command_completion_selected = None;
} }
if let Some(Method::ListRewindTargets) = result.method.as_ref() { let mut method = result.method;
if let Some(Method::Compact { .. }) = method {
method = Some(Method::Compact {
command: self.next_command_envelope(),
});
}
if let Some(Method::ListRewindTargets) = method.as_ref() {
self.completion = None; self.completion = None;
self.rewind_picker = None; self.rewind_picker = None;
self.rewind_request_pending = true; self.rewind_request_pending = true;
} }
result.method method
} }
fn push_command_diagnostic(&mut self, message: impl Into<String>) { fn push_command_diagnostic(&mut self, message: impl Into<String>) {
@@ -2681,7 +2763,10 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::text("selected rewind input")], input: vec![Segment::text("selected rewind input")],
summary: summary(3), summary: summary(3),
}); });
@@ -2700,7 +2785,10 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::text("rewound input")], input: vec![Segment::text("rewound input")],
summary: summary(1), summary: summary(1),
}); });
@@ -2743,7 +2831,10 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::text("rewound input")], input: vec![Segment::text("rewound input")],
summary: summary(2), summary: summary(2),
}); });
@@ -2752,8 +2843,8 @@ mod rewind_refresh_tests {
}); });
assert!(!blocks_contain(&app, "stale tail after rewind")); assert!(!blocks_contain(&app, "stale tail after rewind"));
app.handle_worker_event(Event::Status { app.handle_worker_event(Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}); });
app.handle_worker_event(Event::TextDelta { app.handle_worker_event(Event::TextDelta {
text: "new live tail after status".into(), text: "new live tail after status".into(),
@@ -2877,7 +2968,7 @@ mod composer_history_persistence_tests {
path: "src/lib.rs".into(), path: "src/lib.rs".into(),
}, },
]); ]);
assert!(matches!(app.submit_input(), Some(Method::Run { .. }))); assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
let mut reloaded = App::new_with_input_history_store("test".into(), store); let mut reloaded = App::new_with_input_history_store("test".into(), store);
assert!(reloaded.browse_input_history_older()); assert!(reloaded.browse_input_history_older());
@@ -2958,7 +3049,7 @@ mod composer_history_persistence_tests {
app.insert_char(c); app.insert_char(c);
} }
match app.submit_input() { match app.submit_input() {
Some(Method::Run { input }) => input, Some(Method::Submit { input, .. }) => input,
other => panic!("expected Run, got {other:?}"), other => panic!("expected Run, got {other:?}"),
} }
} }
@@ -3424,72 +3515,44 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn running_submit_is_queued_locally_and_clears_composer() { fn running_submit_is_sent_to_the_worker_and_not_queued_locally() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "queued turn"); insert_text(&mut app, "queued turn");
assert!(app.submit_input().is_none()); let method = app.submit_input();
assert_eq!(app.queued_input_count(), 1); assert!(matches!(method, Some(Method::Submit { .. })));
assert_eq!(app.next_queued_input_preview(), Some("queued turn")); assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
} }
#[test] #[test]
fn finished_run_auto_sends_next_queued_input() { fn pending_submission_projection_is_worker_authoritative() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.handle_worker_event(Event::PendingSubmissionsChanged {
insert_text(&mut app, "next turn"); pending: protocol::PendingSubmissionsSnapshot {
assert!(app.submit_input().is_none()); revision: 3,
notification_count: 0,
let method = app.handle_worker_event(Event::RunEnd { head_id: Some("submission-1".into()),
result: RunResult::Finished, submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
accepted_at_ms: 7,
segment_count: 2,
byte_len: 42,
}],
},
}); });
match method { assert_eq!(app.queued_input_count(), 1);
Some(Method::Run { input }) => { assert_eq!(app.next_queued_input_preview(), Some("submission-1"));
assert_eq!(Segment::flatten_to_text(&input), "next turn"); assert!(
} app.handle_worker_event(Event::RunEnd {
other => panic!("expected queued Run, got {other:?}"), result: RunResult::Finished,
} })
assert_eq!(app.queued_input_count(), 0); .is_none()
} );
assert_eq!(app.queued_input_count(), 1);
#[test]
fn limit_reached_run_auto_sends_next_queued_input() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "next after limit");
assert!(app.submit_input().is_none());
let method = app.handle_worker_event(Event::RunEnd {
result: RunResult::LimitReached,
});
match method {
Some(Method::Run { input }) => {
assert_eq!(Segment::flatten_to_text(&input), "next after limit");
}
other => panic!("expected queued Run, got {other:?}"),
}
assert_eq!(app.queued_input_count(), 0);
}
#[test]
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
for result in [RunResult::Paused, RunResult::RolledBack] {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "held turn");
assert!(app.submit_input().is_none());
let method = app.handle_worker_event(Event::RunEnd { result });
assert!(method.is_none());
assert_eq!(app.queued_input_count(), 1);
assert_eq!(app.next_queued_input_preview(), Some("held turn"));
}
} }
#[test] #[test]
@@ -3497,25 +3560,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Paused); app.set_worker_status(WorkerStatus::Paused);
assert!(matches!(app.submit_input(), Some(Method::Resume))); assert!(matches!(app.submit_input(), Some(Method::Resume { .. })));
assert_eq!(app.queued_input_count(), 0);
}
#[test]
fn queued_input_can_be_restored_to_composer_or_cleared() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "edit me");
assert!(app.submit_input().is_none());
assert!(app.restore_next_queued_input_to_composer());
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "edit me");
app.input.clear();
insert_text(&mut app, "clear me");
assert!(app.submit_input().is_none());
assert_eq!(app.clear_queued_inputs(), 1);
assert_eq!(app.queued_input_count(), 0); assert_eq!(app.queued_input_count(), 0);
} }
@@ -3530,7 +3575,7 @@ mod completion_flow_tests {
app.insert_char(c); app.insert_char(c);
} }
match app.submit_input() { match app.submit_input() {
Some(Method::Run { input }) => input, Some(Method::Submit { input, .. }) => input,
other => panic!("expected Run, got {other:?}"), other => panic!("expected Run, got {other:?}"),
} }
} }
@@ -3570,7 +3615,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: public_session(vec![session_start_value]), session: public_session(vec![session_start_value]),
status: WorkerStatus::Running, state: test_worker_state(WorkerStatus::Running),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -3581,6 +3626,75 @@ mod completion_flow_tests {
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_)))); assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
} }
#[test]
fn occurrence_events_do_not_infer_foreground_worker_state() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::TurnStart { turn: 1 });
app.handle_worker_event(Event::InvokeStart {
kind: protocol::InvokeKind::UserSend,
});
app.handle_worker_event(Event::RunEnd {
result: RunResult::Paused,
});
assert_eq!(app.worker_state.state, protocol::WorkerState::Idle);
assert_eq!(app.worker_status, WorkerStatus::Idle);
let running = WorkerStateSnapshot {
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 0,
};
app.handle_worker_event(Event::WorkerState {
snapshot: running.clone(),
});
app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
});
assert_eq!(app.worker_state, running);
assert_eq!(app.worker_status, WorkerStatus::Running);
}
#[test]
fn worker_state_events_and_acknowledgements_replace_full_state() {
let mut app = App::new("test".into());
let running = WorkerStateSnapshot {
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 2,
};
app.handle_worker_event(Event::WorkerState {
snapshot: running.clone(),
});
assert_eq!(app.worker_state, running);
let fresh_idle = WorkerStateSnapshot {
state: protocol::WorkerState::Idle,
last_command_id: 0,
};
app.handle_worker_event(Event::WorkerState {
snapshot: fresh_idle.clone(),
});
assert_eq!(app.worker_state, fresh_idle);
let paused = WorkerStateSnapshot {
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Paused,
)),
last_command_id: 3,
};
app.handle_worker_event(Event::CommandAcknowledged {
acknowledgement: protocol::WorkerCommandAcknowledgement {
command_id: 3,
command: protocol::WorkerCommandKind::Pause,
disposition: protocol::WorkerCommandDisposition::Accepted,
state: paused.clone(),
},
});
assert_eq!(app.worker_state, paused);
}
#[test] #[test]
fn snapshot_replaces_live_error_with_one_durable_run_error_block() { fn snapshot_replaces_live_error_with_one_durable_run_error_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -3588,8 +3702,8 @@ mod completion_flow_tests {
code: ErrorCode::ProviderError, code: ErrorCode::ProviderError,
message: "provider unavailable".into(), message: "provider unavailable".into(),
}); });
app.handle_worker_event(Event::Status { app.handle_worker_event(Event::WorkerState {
status: WorkerStatus::Idle, snapshot: WorkerStatus::Idle.into(),
}); });
let live_errors = app let live_errors = app
@@ -3614,7 +3728,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: public_session(vec![serde_json::to_value(run_errored).unwrap()]), session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -3675,9 +3789,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Running, state: test_worker_state(WorkerStatus::Running),
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
blocks: vec![ blocks: vec![
InFlightBlock::Thinking { InFlightBlock::Thinking {
@@ -3696,6 +3811,7 @@ mod completion_flow_tests {
}, },
], ],
commands: Vec::new(), commands: Vec::new(),
compaction: None,
}, },
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -3783,6 +3899,7 @@ mod completion_flow_tests {
revision, revision,
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
in_flight: protocol::InFlightSnapshot::default(), in_flight: protocol::InFlightSnapshot::default(),
@@ -4000,9 +4117,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -4051,9 +4169,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: vec![InternalWorkerSnapshot { internal_workers: vec![InternalWorkerSnapshot {
worker: InternalWorkerRef { worker: InternalWorkerRef {
@@ -4064,6 +4183,7 @@ mod completion_flow_tests {
}, },
revision: 4, revision: 4,
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Running, status: WorkerStatus::Running,
@@ -4141,6 +4261,7 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.revision = 2;
lifecycle.new_segment_id = Some(id.to_string()); lifecycle.new_segment_id = Some(id.to_string());
app.handle_worker_event(Event::CompactDone { lifecycle }); app.handle_worker_event(Event::CompactDone { lifecycle });
@@ -4162,6 +4283,7 @@ mod completion_flow_tests {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running), lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
}); });
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed); let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
lifecycle.revision = 2;
lifecycle.error = Some("provider 429".into()); lifecycle.error = Some("provider 429".into());
app.handle_worker_event(Event::CompactFailed { lifecycle }); app.handle_worker_event(Event::CompactFailed { lifecycle });
@@ -4175,6 +4297,52 @@ mod completion_flow_tests {
)); ));
} }
#[test]
fn compaction_progress_is_hidden_when_worker_state_is_inconsistent() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactionProgress {
compaction: Some(protocol::InFlightCompaction {
phase: protocol::CompactionPhase::Preparing,
started_at_ms: 100,
trigger: protocol::CompactionTrigger::Manual,
}),
});
assert!(app.compaction_progress.is_none());
}
#[test]
fn snapshot_restores_and_runtime_clear_removes_compaction_progress() {
let mut app = App::new("test".into());
assert_eq!(app.worker_state.state, protocol::WorkerState::Idle);
let mut state = protocol::WorkerStateSnapshot::initial();
state.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Maintenance(
protocol::WorkerMaintenanceState::Compacting,
));
app.handle_worker_event(Event::Snapshot {
session: public_session(Vec::new()),
greeting: test_greeting(),
state,
in_flight: InFlightSnapshot {
compaction: Some(protocol::InFlightCompaction {
phase: protocol::CompactionPhase::Summarizing,
started_at_ms: 100,
trigger: protocol::CompactionTrigger::Manual,
}),
..InFlightSnapshot::default()
},
internal_workers: Vec::new(),
});
assert_eq!(compact_block_count(&app), 0);
assert_eq!(
app.compaction_progress.as_ref().map(|item| item.phase),
Some(protocol::CompactionPhase::Summarizing)
);
app.handle_worker_event(Event::CompactionProgress { compaction: None });
assert!(app.compaction_progress.is_none());
}
#[test] #[test]
fn shutdown_marks_live_compact_incomplete() { fn shutdown_marks_live_compact_incomplete() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -4200,6 +4368,10 @@ mod completion_flow_tests {
.count() .count()
} }
fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot {
WorkerStateSnapshot::from(status)
}
fn test_greeting() -> protocol::Greeting { fn test_greeting() -> protocol::Greeting {
protocol::Greeting { protocol::Greeting {
worker_name: "test".into(), worker_name: "test".into(),
@@ -4222,10 +4394,11 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
greeting, greeting,
status: WorkerStatus::Idle, state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -4424,7 +4597,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: public_session(assistant_item_entries), session: public_session(assistant_item_entries),
status: WorkerStatus::Running, state: test_worker_state(WorkerStatus::Running),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
@@ -4437,23 +4610,23 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn input_history_records_queued_inputs_and_suppresses_consecutive_duplicates() { fn input_history_records_running_submits_and_suppresses_consecutive_duplicates() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.running = true; app.running = true;
for c in "repeat".chars() { for c in "repeat".chars() {
app.insert_char(c); app.insert_char(c);
} }
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_some());
assert_eq!(app.input_history_len(), 1); assert_eq!(app.input_history_len(), 1);
assert_eq!(app.queued_input_count(), 1); assert_eq!(app.queued_input_count(), 0);
for c in "repeat".chars() { for c in "repeat".chars() {
app.insert_char(c); app.insert_char(c);
} }
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_some());
assert_eq!(app.input_history_len(), 1); assert_eq!(app.input_history_len(), 1);
assert_eq!(app.queued_input_count(), 2); assert_eq!(app.queued_input_count(), 0);
app.insert_char(' '); app.insert_char(' ');
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_none());
@@ -4481,7 +4654,7 @@ mod completion_flow_tests {
}, },
]; ];
app.input.replace_with_segments(&original); app.input.replace_with_segments(&original);
assert!(matches!(app.submit_input(), Some(Method::Run { .. }))); assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
assert!(app.browse_input_history_older()); assert!(app.browse_input_history_older());
assert_eq!(app.input.submit_segments(), original); assert_eq!(app.input.submit_segments(), original);
@@ -4493,7 +4666,7 @@ mod completion_flow_tests {
for c in "sent".chars() { for c in "sent".chars() {
app.insert_char(c); app.insert_char(c);
} }
assert!(matches!(app.submit_input(), Some(Method::Run { .. }))); assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
for c in "draft".chars() { for c in "draft".chars() {
app.insert_char(c); app.insert_char(c);
@@ -4511,7 +4684,7 @@ mod completion_flow_tests {
for c in "sent".chars() { for c in "sent".chars() {
app.insert_char(c); app.insert_char(c);
} }
assert!(matches!(app.submit_input(), Some(Method::Run { .. }))); assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
assert!(app.browse_input_history_older()); assert!(app.browse_input_history_older());
assert!(app.input_history_is_browsing()); assert!(app.input_history_is_browsing());
@@ -4528,17 +4701,19 @@ mod completion_flow_tests {
for c in "first".chars() { for c in "first".chars() {
app.insert_char(c); app.insert_char(c);
} }
assert!(matches!(app.submit_input(), Some(Method::Run { .. }))); assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
for c in "second".chars() { for c in "second".chars() {
app.insert_char(c); app.insert_char(c);
} }
assert!(matches!(app.submit_input(), Some(Method::Run { .. }))); assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
assert!(app.browse_input_history_older()); assert!(app.browse_input_history_older());
assert!(app.browse_input_history_older()); assert!(app.browse_input_history_older());
let method = app.submit_input(); let method = app.submit_input();
match method { match method {
Some(Method::Run { input }) => assert_eq!(Segment::flatten_to_text(&input), "first"), Some(Method::Submit { input, .. }) => {
assert_eq!(Segment::flatten_to_text(&input), "first")
}
other => panic!("expected recalled run, got {other:?}"), other => panic!("expected recalled run, got {other:?}"),
} }
assert_eq!(app.input_history_len(), 3); assert_eq!(app.input_history_len(), 3);
+483
View File
@@ -0,0 +1,483 @@
use client::{
BackendCreateWorkerRequest, BackendWorkerLaunchOptions, BackendWorkerLaunchProfileCandidate,
BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchTarget, create_backend_worker,
get_backend_worker_launch_options,
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::backend_workspace_picker::select_backend_workspace;
use crate::console;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Name,
Runtime,
Profile,
}
impl Field {
fn next(self) -> Self {
match self {
Self::Name => Self::Runtime,
Self::Runtime => Self::Profile,
Self::Profile => Self::Name,
}
}
fn previous(self) -> Self {
match self {
Self::Name => Self::Profile,
Self::Runtime => Self::Name,
Self::Profile => Self::Runtime,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Selection {
runtime_id: String,
display_name: String,
profile: String,
}
struct FormState {
field: Field,
display_name: String,
runtime_index: usize,
profile_index: usize,
status: String,
}
impl FormState {
fn new(options: &BackendWorkerLaunchOptions) -> Self {
let runtime_index = options
.runtimes
.iter()
.position(runtime_supports_workdirless_creation)
.unwrap_or(0);
let profile_index = options
.default_profile
.as_deref()
.and_then(|default| {
options
.profiles
.iter()
.position(|candidate| candidate.id == default)
})
.unwrap_or(0);
Self {
field: Field::Name,
display_name: "Worker".to_string(),
runtime_index,
profile_index,
status: String::new(),
}
}
fn current_runtime<'a>(
&self,
options: &'a BackendWorkerLaunchOptions,
) -> Option<&'a BackendWorkerLaunchRuntimeOption> {
options.runtimes.get(self.runtime_index)
}
fn current_profile<'a>(
&self,
options: &'a BackendWorkerLaunchOptions,
) -> Option<&'a BackendWorkerLaunchProfileCandidate> {
options.profiles.get(self.profile_index)
}
fn cycle_runtime(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
self.runtime_index = cycle_index(self.runtime_index, options.runtimes.len(), delta);
self.status.clear();
}
fn cycle_profile(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
self.profile_index = cycle_index(self.profile_index, options.profiles.len(), delta);
self.status.clear();
}
fn submit(&mut self, options: &BackendWorkerLaunchOptions) -> Option<Selection> {
let display_name = self.display_name.trim();
if display_name.is_empty() {
self.status = "Worker name is required.".to_string();
self.field = Field::Name;
return None;
}
let Some(runtime) = self.current_runtime(options) else {
self.status = "No Runtime is available in this Workspace.".to_string();
self.field = Field::Runtime;
return None;
};
if !runtime.worker_creation_available {
self.status = "The selected Runtime cannot create Workers right now.".to_string();
self.field = Field::Runtime;
return None;
}
if runtime.working_directory_required {
self.status =
"The selected Runtime requires a workdir; this launch flow does not select one yet."
.to_string();
self.field = Field::Runtime;
return None;
}
let Some(profile) = self.current_profile(options) else {
self.status = "No Worker profile is available.".to_string();
self.field = Field::Profile;
return None;
};
Some(Selection {
runtime_id: runtime.runtime_id.clone(),
display_name: display_name.to_string(),
profile: profile.id.clone(),
})
}
}
pub async fn run(mut target: BackendWorkerLaunchTarget) -> Result<(), Box<dyn std::error::Error>> {
if target.workspace_id().is_none() {
let Some(workspace) = select_backend_workspace(&target.base_url).await? else {
return Ok(());
};
target.select_workspace(workspace);
}
let options = get_backend_worker_launch_options(&target).await?;
let Some(selection) = select_worker(&options)? else {
return Ok(());
};
let request = request_from_selection(selection);
let created = create_backend_worker(&target, &request).await?;
let runtime_target = target.runtime_target(created.runtime_id, created.worker_id)?;
console::run_backend_runtime(runtime_target).await
}
fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
BackendCreateWorkerRequest {
runtime_id: selection.runtime_id,
display_name: selection.display_name,
profile: Some(selection.profile),
initial_submit: Vec::new(),
working_directory: None,
ticket_assignment: None,
control_operation_id: None,
}
}
const VIEWPORT_LINES: u16 = 14;
fn select_worker(
options: &BackendWorkerLaunchOptions,
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
with_inline_terminal(VIEWPORT_LINES, |terminal| run_form(terminal, options))
}
fn run_form(
terminal: &mut InlineTerminal,
options: &BackendWorkerLaunchOptions,
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
let mut state = FormState::new(options);
loop {
terminal.draw(|frame| render(frame, &state, options))?;
let event = event::read()?;
let Event::Key(key) = event else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(None);
}
match key.code {
KeyCode::Esc => {
return Ok(None);
}
KeyCode::Tab | KeyCode::Down => {
state.field = state.field.next();
state.status.clear();
}
KeyCode::BackTab | KeyCode::Up => {
state.field = state.field.previous();
state.status.clear();
}
KeyCode::Left => match state.field {
Field::Runtime => state.cycle_runtime(options, -1),
Field::Profile => state.cycle_profile(options, -1),
Field::Name => {}
},
KeyCode::Right => match state.field {
Field::Runtime => state.cycle_runtime(options, 1),
Field::Profile => state.cycle_profile(options, 1),
Field::Name => {}
},
KeyCode::Enter => {
if let Some(selection) = state.submit(options) {
return Ok(Some(selection));
}
}
KeyCode::Backspace if state.field == Field::Name => {
state.display_name.pop();
state.status.clear();
}
KeyCode::Char(character)
if state.field == Field::Name
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !character.is_control() =>
{
state.display_name.push(character);
state.status.clear();
}
_ => {}
}
}
}
fn render(frame: &mut ratatui::Frame<'_>, state: &FormState, options: &BackendWorkerLaunchOptions) {
let area = frame.area();
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(1),
])
.split(area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"New Backend Worker",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(" Workspace: {}", options.workspace_id)),
])),
vertical[0],
);
let focused = Style::default().fg(Color::Cyan);
frame.render_widget(
Paragraph::new(state.display_name.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(" Name ")
.border_style(if state.field == Field::Name {
focused
} else {
Style::default()
}),
),
vertical[1],
);
let runtime_text = state
.current_runtime(options)
.map(runtime_label)
.unwrap_or_else(|| "No Runtime available".to_string());
frame.render_widget(
Paragraph::new(runtime_text).block(
Block::default()
.borders(Borders::ALL)
.title(runtime_title(state, options))
.border_style(if state.field == Field::Runtime {
focused
} else {
Style::default()
}),
),
vertical[2],
);
let profile_text = state
.current_profile(options)
.map(|profile| {
if profile.description.is_empty() {
profile.label.clone()
} else {
format!("{}{}", profile.label, profile.description)
}
})
.unwrap_or_else(|| "No profile available".to_string());
frame.render_widget(
Paragraph::new(profile_text).block(
Block::default()
.borders(Borders::ALL)
.title(profile_title(state, options))
.border_style(if state.field == Field::Profile {
focused
} else {
Style::default()
}),
),
vertical[3],
);
let status = if state.status.is_empty() {
"Tab/↑/↓: field ←/→: choice Enter: create Esc/Ctrl-C: cancel"
} else {
state.status.as_str()
};
frame.render_widget(
Paragraph::new(status)
.style(if state.status.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Yellow)
})
.wrap(Wrap { trim: true }),
vertical[4],
);
if state.field == Field::Name {
let max_cursor = vertical[1].width.saturating_sub(2) as usize;
frame.set_cursor_position((
vertical[1].x + 1 + state.display_name.chars().count().min(max_cursor) as u16,
vertical[1].y + 1,
));
}
}
fn runtime_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
if options.runtimes.is_empty() {
" Runtime ".to_string()
} else {
format!(
" Runtime ({}/{}) ",
state.runtime_index + 1,
options.runtimes.len()
)
}
}
fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
if options.profiles.is_empty() {
" Profile ".to_string()
} else {
format!(
" Profile ({}/{}) ",
state.profile_index + 1,
options.profiles.len()
)
}
}
fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
let availability = if !runtime.worker_creation_available {
"unavailable"
} else if runtime.working_directory_required {
"workdir required"
} else {
"no workdir"
};
format!(
"{} [{}] — {availability}",
runtime.display_name, runtime.runtime_id
)
}
fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool {
runtime.worker_creation_available && !runtime.working_directory_required
}
fn cycle_index(current: usize, len: usize, delta: isize) -> usize {
if len == 0 {
return 0;
}
(current as isize + delta).rem_euclid(len as isize) as usize
}
#[cfg(test)]
mod tests {
use super::*;
use client::{BackendDiagnostic, BackendWorkerLaunchOptions};
fn options() -> BackendWorkerLaunchOptions {
BackendWorkerLaunchOptions {
workspace_id: "workspace-1".to_string(),
runtimes: vec![
BackendWorkerLaunchRuntimeOption {
runtime_id: "external".to_string(),
display_name: "External".to_string(),
built_in: false,
worker_creation_available: true,
working_directory_required: true,
status: "online".to_string(),
diagnostics: Vec::new(),
},
BackendWorkerLaunchRuntimeOption {
runtime_id: "embedded".to_string(),
display_name: "Embedded".to_string(),
built_in: true,
worker_creation_available: true,
working_directory_required: false,
status: "online".to_string(),
diagnostics: Vec::new(),
},
],
profiles: vec![
BackendWorkerLaunchProfileCandidate {
id: "builtin:default".to_string(),
label: "Default".to_string(),
description: String::new(),
},
BackendWorkerLaunchProfileCandidate {
id: "builtin:coder".to_string(),
label: "Coder".to_string(),
description: "Ticket implementation".to_string(),
},
],
default_profile: Some("builtin:coder".to_string()),
repositories: Vec::new(),
working_directories: Vec::new(),
diagnostics: Vec::<BackendDiagnostic>::new(),
}
}
#[test]
fn defaults_to_workdirless_runtime_and_backend_default_profile() {
let options = options();
let state = FormState::new(&options);
assert_eq!(
state.current_runtime(&options).unwrap().runtime_id,
"embedded"
);
assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder");
assert_eq!(state.display_name, "Worker");
}
#[test]
fn workdir_required_runtime_cannot_be_submitted() {
let options = options();
let mut state = FormState::new(&options);
state.runtime_index = 0;
assert_eq!(state.submit(&options), None);
assert!(state.status.contains("requires a workdir"));
assert_eq!(state.field, Field::Runtime);
}
#[test]
fn selection_builds_workdirless_create_request() {
let request = request_from_selection(Selection {
runtime_id: "embedded".to_string(),
display_name: "Coder one".to_string(),
profile: "builtin:coder".to_string(),
});
assert_eq!(request.runtime_id, "embedded");
assert_eq!(request.display_name, "Coder one");
assert_eq!(request.profile.as_deref(), Some("builtin:coder"));
assert!(request.initial_submit.is_empty());
assert!(request.working_directory.is_none());
assert!(request.ticket_assignment.is_none());
}
}
+265 -41
View File
@@ -3,8 +3,9 @@ use std::io;
use std::time::Duration; use std::time::Duration;
use client::{ use client::{
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers, BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
list_backend_workers, restore_backend_worker, BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers,
restore_backend_worker,
}; };
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Frame; use ratatui::Frame;
@@ -12,6 +13,7 @@ use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;
use crate::backend_workspace_picker::select_backend_workspace; use crate::backend_workspace_picker::select_backend_workspace;
use crate::console; use crate::console;
@@ -83,17 +85,20 @@ pub(crate) async fn run(
let restore_target = target let restore_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?; .map_err(|error| io::Error::other(error.to_string()))?;
restore_backend_worker(&restore_target) let restore = restore_backend_worker(&restore_target)
.await .await
.map_err(|error| { .map_err(|error| {
io::Error::other(format!( io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}", "failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id selected.runtime_id, selected.worker_id
)) ))
})? })?;
.result restored_worker(restore).map_err(|error| {
.worker io::Error::other(format!(
.unwrap_or(selected) "failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
} else { } else {
selected selected
}; };
@@ -104,6 +109,33 @@ pub(crate) async fn run(
} }
} }
fn restored_worker(response: BackendWorkerRestoreResponse) -> Result<BackendWorkerSummary, String> {
if response.result.state != BackendWorkerOperationState::Accepted {
let diagnostics = response
.result
.diagnostics
.iter()
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
.collect::<Vec<_>>()
.join("; ");
let state = match response.result.state {
BackendWorkerOperationState::Accepted => unreachable!(),
BackendWorkerOperationState::Rejected => "rejected",
BackendWorkerOperationState::Unsupported => "unsupported",
};
return Err(if diagnostics.is_empty() {
format!("restore was {state} without a diagnostic")
} else {
format!("restore was {state}: {diagnostics}")
});
}
response
.result
.worker
.ok_or_else(|| "restore was accepted without a Worker snapshot".to_string())
}
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) { fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone()))); workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
@@ -235,9 +267,10 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
layout[0], layout[0],
); );
let column_widths = WorkerColumnWidths::from_workers(&state.workers);
for (i, worker) in state.workers.iter().enumerate() { for (i, worker) in state.workers.iter().enumerate() {
frame.render_widget( frame.render_widget(
Paragraph::new(row_line(worker, i == state.selected)), Paragraph::new(row_line(worker, &column_widths, i == state.selected)),
layout[i + 1], layout[i + 1],
); );
} }
@@ -272,7 +305,28 @@ fn picker_title(target: &BackendRuntimeListTarget) -> String {
format!("backend workers workspace: {workspace} runtime: {runtime}") format!("backend workers workspace: {workspace} runtime: {runtime}")
} }
fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct WorkerColumnWidths {
identity: usize,
name: usize,
state: usize,
}
impl WorkerColumnWidths {
fn from_workers(workers: &[BackendWorkerSummary]) -> Self {
workers.iter().fold(Self::default(), |widths, worker| Self {
identity: widths.identity.max(text_width(&short_worker_id(worker))),
name: widths.name.max(text_width(worker_name(worker))),
state: widths.state.max(text_width(&worker_state(worker))),
})
}
}
fn row_line(
worker: &BackendWorkerSummary,
widths: &WorkerColumnWidths,
selected: bool,
) -> Line<'static> {
let marker = if selected { "" } else { " " }; let marker = if selected { "" } else { " " };
let id_style = if selected { let id_style = if selected {
Style::default() Style::default()
@@ -281,42 +335,70 @@ fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
} else { } else {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
}; };
let preview_style = if selected { let name_style = if selected {
Style::default().fg(Color::White) Style::default().fg(Color::White)
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
}; };
let label = if worker.label.is_empty() {
worker.worker_id.as_str()
} else {
worker.label.as_str()
};
let profile = worker.profile.as_deref().unwrap_or("-");
Line::from(vec![ Line::from(vec![
Span::raw(marker), Span::raw(marker),
Span::styled(short_worker_id(worker), id_style),
Span::raw(" "),
Span::styled( Span::styled(
format!("[{}]", worker.state), pad_column(&short_worker_id(worker), widths.identity),
state_style(worker.state.as_str()), id_style,
), ),
Span::raw(" "), Span::raw(" "),
Span::styled(pad_column(worker_name(worker), widths.name), name_style),
Span::raw(" "),
Span::styled( Span::styled(
format!("profile:{profile}"), pad_column(&worker_state(worker), widths.state),
Style::default().fg(Color::DarkGray), state_style(worker_state_label(worker)),
), ),
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
working_directory_text(worker), working_directory_text(worker),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
), ),
Span::raw(" "),
Span::styled(label.to_string(), preview_style),
]) ])
} }
fn worker_name(worker: &BackendWorkerSummary) -> &str {
if !worker.label.is_empty() {
worker.label.as_str()
} else if !worker.display_name.is_empty() {
worker.display_name.as_str()
} else {
worker.worker_id.as_str()
}
}
fn worker_state_label(worker: &BackendWorkerSummary) -> &str {
match worker.worker_state.as_ref().map(|state| &state.state) {
Some(protocol::WorkerState::Idle) => "idle",
Some(protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Paused,
))) => "paused",
Some(protocol::WorkerState::Busy(_)) => "running",
None if worker.state == "stopped" => "stopped",
None => "unknown",
}
}
fn worker_state(worker: &BackendWorkerSummary) -> String {
format!("[{}]", worker_state_label(worker))
}
fn text_width(value: &str) -> usize {
UnicodeWidthStr::width(value)
}
fn pad_column(value: &str, width: usize) -> String {
format!(
"{value}{}",
" ".repeat(width.saturating_sub(text_width(value)))
)
}
fn state_style(state: &str) -> Style { fn state_style(state: &str) -> Style {
match state { match state {
"running" | "idle" | "active" => Style::default() "running" | "idle" | "active" => Style::default()
@@ -347,18 +429,15 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
let Some(wd) = worker.working_directory.as_ref() else { let Some(wd) = worker.working_directory.as_ref() else {
return "wd:—".to_string(); return "wd:—".to_string();
}; };
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown"); format!("wd:{}{}", wd.repository_key, wd.working_directory_id)
format!(
"wd:{}:{} {} {}",
wd.repository_key, wd.working_directory_id, wd.status, cleanliness
)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use client::{ use client::{
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
BackendWorkerWorkspaceSummary, BackendWorkerWorkspaceSummary,
}; };
@@ -378,7 +457,13 @@ mod tests {
identity: "ws".to_string(), identity: "ws".to_string(),
workspace_id: Some("ws".to_string()), workspace_id: Some("ws".to_string()),
}, },
state: "running".to_string(), state: "idle".to_string(),
worker_state: Some(protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 0,
}),
last_seen_at: None, last_seen_at: None,
pinned: false, pinned: false,
retention_state: String::new(), retention_state: String::new(),
@@ -395,18 +480,157 @@ mod tests {
} }
} }
#[test] fn row_text(worker: &BackendWorkerSummary, widths: &WorkerColumnWidths) -> String {
fn worker_row_matches_inline_picker_shape() { row_line(worker, widths, false)
let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true);
let text = row
.spans .spans
.into_iter() .into_iter()
.map(|span| span.content) .map(|span| span.content)
.collect::<String>(); .collect()
assert!(text.starts_with("▶ W-1")); }
assert!(text.contains("[running]"));
assert!(text.contains("profile:default")); fn display_column(text: &str, value: &str) -> usize {
assert!(text.contains("wd:—")); let byte_offset = text.find(value).expect("value in rendered row");
text_width(&text[..byte_offset])
}
fn restore_response(
state: BackendWorkerOperationState,
worker: Option<BackendWorkerSummary>,
diagnostics: Vec<BackendDiagnostic>,
) -> BackendWorkerRestoreResponse {
BackendWorkerRestoreResponse {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
worker_id: "worker-a".to_string(),
result: BackendWorkerRestoreResult {
state,
worker,
diagnostics,
},
}
}
#[test]
fn rejected_restore_surfaces_diagnostic_instead_of_attaching_selected_worker() {
let error = restored_worker(restore_response(
BackendWorkerOperationState::Rejected,
None,
vec![BackendDiagnostic {
code: "working_directory_not_found".to_string(),
severity: BackendDiagnosticSeverity::Error,
message: "working directory was not found".to_string(),
}],
))
.expect_err("rejected restore must not produce a Worker to attach");
assert_eq!(
error,
"restore was rejected: working_directory_not_found: working directory was not found"
);
}
#[test]
fn accepted_restore_requires_returned_worker_snapshot() {
let error = restored_worker(restore_response(
BackendWorkerOperationState::Accepted,
None,
Vec::new(),
))
.expect_err("accepted restore without a Worker must not attach the stale selection");
assert_eq!(error, "restore was accepted without a Worker snapshot");
}
#[test]
fn accepted_restore_returns_authoritative_worker_snapshot() {
let worker = worker("runtime-a", "worker-a", Some("builtin:companion"));
let restored = restored_worker(restore_response(
BackendWorkerOperationState::Accepted,
Some(worker.clone()),
Vec::new(),
))
.expect("accepted restore should return its Worker snapshot");
assert_eq!(restored, worker);
}
#[test]
fn worker_row_orders_and_simplifies_columns() {
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
worker.resource_key = "W-90".to_string();
worker.display_name = "Coder".to_string();
worker.label = "Coder · T-585".to_string();
worker.state = "stopped".to_string();
worker.worker_state = None;
worker.working_directory = Some(
serde_json::from_value(serde_json::json!({
"working_directory_id": "001a06a9f0202000000",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active",
"cleanliness": "clean"
}))
.unwrap(),
);
let widths = WorkerColumnWidths::from_workers(std::slice::from_ref(&worker));
let text = row_text(&worker, &widths);
assert_eq!(
text,
" W-90 Coder · T-585 [stopped] wd:main・001a06a9f0202000000"
);
assert!(!text.contains("profile:"));
assert!(!text.contains("active clean"));
}
#[test]
fn worker_rows_align_identity_name_state_and_workdir_columns() {
let mut short = worker("runtime-a", "worker-a", None);
short.resource_key = "W-2".to_string();
short.label = "Coder".to_string();
short.display_name = short.label.clone();
short.state = "idle".to_string();
short.worker_state = Some(protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Idle,
last_command_id: 0,
});
let mut long = worker("runtime-a", "worker-b", None);
long.resource_key = "W-100".to_string();
long.label = "Longer worker · T-9".to_string();
long.display_name = long.label.clone();
long.state = "stopped".to_string();
long.worker_state = None;
for worker in [&mut short, &mut long] {
worker.working_directory = Some(
serde_json::from_value(serde_json::json!({
"working_directory_id": "workdir-1",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active"
}))
.unwrap(),
);
}
let workers = vec![short, long];
let widths = WorkerColumnWidths::from_workers(&workers);
let first = row_text(&workers[0], &widths);
let second = row_text(&workers[1], &widths);
assert_eq!(
display_column(&first, "Coder"),
display_column(&second, "Longer")
);
assert_eq!(
display_column(&first, "[idle]"),
display_column(&second, "[stopped]")
);
assert_eq!(
display_column(&first, "wd:main"),
display_column(&second, "wd:main")
);
} }
#[test] #[test]
+5 -5
View File
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
println!("Repository path/URI is required."); println!("Repository path/URI is required.");
return Ok(None); return Ok(None);
} }
let repository_name = prompt_line("Repository display name [Main]: ")?; let repository_key = prompt_line("Repository key [main]: ")?;
let default_ref = prompt_line("Default ref [repository default]: ")?; let default_ref = prompt_line("Default ref [repository default]: ")?;
let operation_key = format!( let operation_key = format!(
"tui-workspace-create-{}-{}", "tui-workspace-create-{}-{}",
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
display_name, display_name,
repository: CreateBackendWorkspaceRepository { repository: CreateBackendWorkspaceRepository {
uri, uri,
display_name: Some(if repository_name.is_empty() { repository_key: if repository_key.is_empty() {
"Main".to_string() "main".to_string()
} else { } else {
repository_name repository_key
}), },
default_ref: (!default_ref.is_empty()).then_some(default_ref), default_ref: (!default_ref.is_empty()).then_some(default_ref),
}, },
})) }))
+4 -2
View File
@@ -409,7 +409,9 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment; let _ = invocation.environment;
let _ = invocation.args.raw(); let _ = invocation.args.raw();
CommandExecution { CommandExecution {
method: Some(Method::Compact), method: Some(Method::Compact {
command: protocol::WorkerCommandEnvelope::new(0),
}),
diagnostics: vec![CommandDiagnostic::new("compact requested")], diagnostics: vec![CommandDiagnostic::new("compact requested")],
exit_command_mode: true, exit_command_mode: true,
clear_input: true, clear_input: true,
@@ -483,7 +485,7 @@ mod tests {
fn compact_command_returns_compact_method_not_run() { fn compact_command_returns_compact_method_not_run() {
let registry = CommandRegistry::builtins(); let registry = CommandRegistry::builtins();
let result = registry.dispatch("compact", &env()); let result = registry.dispatch("compact", &env());
assert!(matches!(result.method, Some(Method::Compact))); assert!(matches!(result.method, Some(Method::Compact { .. })));
assert!(result.exit_command_mode); assert!(result.exit_command_mode);
assert!(result.clear_input); assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("compact requested")); assert!(result.diagnostics[0].message.contains("compact requested"));
+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>> { async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
let mut prepared = method.clone(); let mut prepared = method.clone();
let carries_attachments = let carries_attachments =
matches!(prepared, Method::Run { .. }) && !self.pending_attachments.is_empty(); matches!(prepared, Method::Submit { .. }) && !self.pending_attachments.is_empty();
if let Method::Run { input } = &mut prepared { if let Method::Submit { input, .. } = &mut prepared {
input.extend( input.extend(
self.pending_attachments self.pending_attachments
.iter() .iter()
@@ -569,9 +569,10 @@ async fn run_e2e_rewind_fixture(
app.connected = true; app.connected = true;
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
status: WorkerStatus::Idle, state: WorkerStatus::Idle.into(),
greeting: Greeting { greeting: Greeting {
worker_name: worker_name.clone(), worker_name: worker_name.clone(),
cwd: workspace_root.display().to_string(), cwd: workspace_root.display().to_string(),
@@ -697,6 +698,7 @@ async fn run_e2e_rewind_fixture(
if submitted_at.elapsed() >= apply_delay { if submitted_at.elapsed() >= apply_delay {
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(), entries: Vec::new(),
}, },
input: vec![Segment::text("rewind-live-refresh")], input: vec![Segment::text("rewind-live-refresh")],
@@ -916,7 +918,7 @@ async fn run_loop<T: Socket>(
} }
fn attachment_command_path(method: &Method) -> Option<PathBuf> { fn attachment_command_path(method: &Method) -> Option<PathBuf> {
let Method::Run { input } = method else { let Method::Submit { input, .. } = method else {
return None; return None;
}; };
let [Segment::Text { content }] = input.as_slice() else { let [Segment::Text { content }] = input.as_slice() else {
@@ -927,7 +929,7 @@ fn attachment_command_path(method: &Method) -> Option<PathBuf> {
} }
fn is_clear_attachments_command(method: &Method) -> bool { fn is_clear_attachments_command(method: &Method) -> bool {
let Method::Run { input } = method else { let Method::Submit { input, .. } = method else {
return false; return false;
}; };
matches!( matches!(
@@ -941,7 +943,7 @@ async fn send_console_method<T: Socket>(
client: &mut ConsoleConnection<T>, client: &mut ConsoleConnection<T>,
method: &Method, method: &Method,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if matches!(method, Method::Run { .. }) && client.has_active_uploads() { if matches!(method, Method::Submit { .. }) && client.has_active_uploads() {
app.restore_unsent_run(method); app.restore_unsent_run(method);
app.flash_actionbar_notice( app.flash_actionbar_notice(
"Attachment upload is still in progress; wait or use /clear-attachments.", "Attachment upload is still in progress; wait or use /clear-attachments.",
@@ -953,7 +955,7 @@ async fn send_console_method<T: Socket>(
} }
let sends_attachments = let sends_attachments =
matches!(method, Method::Run { .. }) && !client.pending_attachments.is_empty(); matches!(method, Method::Submit { .. }) && !client.pending_attachments.is_empty();
if let Err(error) = client.send(method).await { if let Err(error) = client.send(method).await {
if sends_attachments { if sends_attachments {
app.restore_unsent_run(method); app.restore_unsent_run(method);
@@ -1148,18 +1150,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.clear_command_input(); app.clear_command_input();
Some(None) Some(None)
} }
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'d') && alt && !ctrl && !app.is_command_mode() =>
{
Some(
app.next_queued_input_preview()
.map(str::to_owned)
.map(|submission_id| app.cancel_pending_method(submission_id)),
)
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'n') && alt && !ctrl && !app.is_command_mode() =>
{
Some(app.submit_notify_input())
}
KeyCode::Char(c) KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() => if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
{ {
if app.restore_next_queued_input_to_composer() { Some(app.continue_pending_method())
Some(app.refresh_completion())
} else {
Some(None)
}
} }
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => { KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
app.clear_queued_inputs(); Some(Some(app.clear_pending_method()))
Some(None)
} }
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)), KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)), KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
@@ -1427,14 +1438,15 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
WorkerStatus::Running | WorkerStatus::Paused WorkerStatus::Running | WorkerStatus::Paused
) { ) {
app.shutdown_confirm = None; app.shutdown_confirm = None;
app.clear_queued_inputs(); let command = app.next_command_envelope();
return Some(Method::Cancel); return Some(Method::Cancel { command });
} }
if let Some(pressed_at) = app.shutdown_confirm if let Some(pressed_at) = app.shutdown_confirm
&& pressed_at.elapsed() < CONFIRM_TIMEOUT && pressed_at.elapsed() < CONFIRM_TIMEOUT
{ {
app.shutdown_confirm = None; app.shutdown_confirm = None;
return Some(Method::Shutdown); let command = app.next_command_envelope();
return Some(Method::Shutdown { command });
} }
app.shutdown_confirm = Some(std::time::Instant::now()); app.shutdown_confirm = Some(std::time::Instant::now());
app.flash_actionbar_notice( app.flash_actionbar_notice(
@@ -1450,8 +1462,8 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running). /// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> { fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.worker_status == WorkerStatus::Running { if app.worker_status == WorkerStatus::Running {
app.clear_queued_inputs(); let command = app.next_command_envelope();
return Some(Method::Pause); return Some(Method::Pause { command });
} }
if let Some(t) = app.quit_confirm if let Some(t) = app.quit_confirm
&& t.elapsed() < CONFIRM_TIMEOUT && t.elapsed() < CONFIRM_TIMEOUT
@@ -1476,8 +1488,8 @@ mod tests {
use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::text_selection::{HistoryViewport, SelectionRow};
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{ use protocol::{
Event, RewindTarget, RewindTargetId, RunResult, Segment, UploadedFileAvailability, Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
UploadedFileRef, WorkerStatus, WorkerStatus,
}; };
#[test] #[test]
@@ -1490,7 +1502,8 @@ mod tests {
#[test] #[test]
fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() { fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() {
let attach = Method::Run { let attach = Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: vec![Segment::text("/attach /tmp/report.md")], input: vec![Segment::text("/attach /tmp/report.md")],
}; };
assert_eq!( assert_eq!(
@@ -1499,7 +1512,8 @@ mod tests {
); );
assert!(!is_clear_attachments_command(&attach)); assert!(!is_clear_attachments_command(&attach));
let clear = Method::Run { let clear = Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: vec![Segment::text("/clear-attachments")], input: vec![Segment::text("/clear-attachments")],
}; };
assert!(is_clear_attachments_command(&clear)); assert!(is_clear_attachments_command(&clear));
@@ -1605,7 +1619,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn queued_attachment_send_failure_restores_draft_without_exiting_console() { async fn running_attachment_submit_failure_restores_draft_without_exiting_console() {
let file = UploadedFileRef { let file = UploadedFileRef {
artifact_id: "artifact-queued".into(), artifact_id: "artifact-queued".into(),
file_name: "queued.txt".into(), file_name: "queued.txt".into(),
@@ -1631,13 +1645,10 @@ mod tests {
let mut app = App::new("worker".into()); let mut app = App::new("worker".into());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
app.input.insert_str("queued inspect"); app.input.insert_str("queued inspect");
assert!(app.submit_input().is_none());
let method = app let method = app
.handle_worker_event(Event::RunEnd { .submit_input()
result: RunResult::Finished, .expect("running Submit is sent immediately");
})
.expect("queued run must be released");
send_console_method(&mut app, &mut connection, &method) send_console_method(&mut app, &mut connection, &method)
.await .await
.unwrap(); .unwrap();
@@ -1960,7 +1971,7 @@ mod tests {
} }
#[test] #[test]
fn running_enter_queues_instead_of_sending_run() { fn running_enter_sends_submit_to_worker() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() { for c in "queued".chars() {
@@ -1973,102 +1984,128 @@ mod tests {
); );
} }
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); assert!(matches!(
handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(Method::Submit { .. })
));
assert_eq!(app.queued_input_count(), 1); assert_eq!(app.queued_input_count(), 0);
assert_eq!(app.next_queued_input_preview(), Some("queued"));
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
} }
#[test] #[test]
fn queued_input_keybindings_restore_and_clear() { fn running_alt_n_sends_explicit_notify_without_implicit_submit_conversion() {
let mut app = App::new("agent".to_string()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "edit queued".chars() { for character in "progress".chars() {
assert!( app.insert_char(character);
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
} }
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!( let method = handle_key(
handle_key( &mut app,
&mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::ALT),
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
)
.is_none()
); );
assert_eq!(app.queued_input_count(), 0); assert!(matches!(
assert_eq!(input_text(&app), "edit queued"); method,
Some(Method::Notify {
app.input.clear(); ref message,
for c in "clear queued".chars() { auto_run: true,
assert!( ..
handle_key( }) if message == "progress"
&mut app, ));
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) assert_eq!(input_text(&app), "");
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert_eq!(app.queued_input_count(), 1);
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT)
)
.is_none()
);
assert_eq!(app.queued_input_count(), 0);
} }
#[test] #[test]
fn pause_and_cancel_clear_queued_input() { fn pending_queue_shortcuts_send_worker_operations() {
let mut app = App::new("agent".to_string()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); app.handle_worker_event(Event::PendingSubmissionsChanged {
for c in "queued".chars() { pending: protocol::PendingSubmissionsSnapshot {
assert!( revision: 2,
handle_key( notification_count: 0,
&mut app, head_id: Some("submission-1".into()),
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) submissions: vec![protocol::PendingSubmissionSummary {
) submission_id: "submission-1".into(),
.is_none() accepted_at_ms: 1,
); segment_count: 1,
} byte_len: 6,
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none()); }],
assert_eq!(app.queued_input_count(), 1); },
});
let pause = handle_key( let continue_next = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT),
); );
assert!(matches!(pause, Some(Method::Pause))); assert!(matches!(
assert_eq!(app.queued_input_count(), 0); continue_next,
Some(Method::ContinuePending {
for c in "queued again".chars() { expected_revision: 2,
assert!( ref expected_head_id,
handle_key( }) if expected_head_id == "submission-1"
&mut app, ));
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert_eq!(app.queued_input_count(), 1); assert_eq!(app.queued_input_count(), 1);
let cancel = handle_key( let cancel = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT),
); );
assert!(matches!(cancel, Some(Method::Cancel))); assert!(matches!(
assert_eq!(app.queued_input_count(), 0); cancel,
Some(Method::CancelPendingSubmission {
expected_revision: 2,
ref submission_id,
}) if submission_id == "submission-1"
));
let clear = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT),
);
assert!(matches!(
clear,
Some(Method::ClearPendingSubmissions {
expected_revision: 2
})
));
assert_eq!(app.queued_input_count(), 1);
}
#[test]
fn pause_and_cancel_preserve_authoritative_pending_queue() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 6,
}],
},
});
app.set_worker_status(WorkerStatus::Running);
assert!(matches!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
),
Some(Method::Pause { .. })
));
assert_eq!(app.queued_input_count(), 1);
app.set_worker_status(WorkerStatus::Running);
assert!(matches!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
),
Some(Method::Cancel { .. })
));
assert_eq!(app.queued_input_count(), 1);
} }
#[test] #[test]
@@ -2080,7 +2117,7 @@ mod tests {
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
); );
assert!(matches!(cancel, Some(Method::Cancel))); assert!(matches!(cancel, Some(Method::Cancel { .. })));
} }
#[test] #[test]
@@ -2102,7 +2139,7 @@ mod tests {
assert!(matches!( assert!(matches!(
handle_key(&mut app, ctrl_x()), handle_key(&mut app, ctrl_x()),
Some(Method::Shutdown) Some(Method::Shutdown { .. })
)); ));
assert!(app.shutdown_confirm.is_none()); assert!(app.shutdown_confirm.is_none());
} }
@@ -2432,7 +2469,7 @@ mod tests {
} }
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(method, Some(protocol::Method::Compact))); assert!(matches!(method, Some(protocol::Method::Compact { .. })));
assert!(!app.is_command_mode()); assert!(!app.is_command_mode());
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
assert_eq!(app.queued_input_count(), 0); assert_eq!(app.queued_input_count(), 0);
@@ -2535,13 +2572,19 @@ mod tests {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
status: WorkerStatus::Idle, pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::Text { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
}], }],
@@ -2562,15 +2605,21 @@ mod tests {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
status: WorkerStatus::Idle, pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
type_keys(&mut app, "draft"); type_keys(&mut app, "draft");
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] }, session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::Text { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
}], }],
@@ -2697,8 +2746,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
enter_command_mode(&mut app); enter_command_mode(&mut app);
@@ -2813,8 +2862,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
@@ -2839,8 +2888,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
handle_key(&mut app, key(KeyCode::Tab)); handle_key(&mut app, key(KeyCode::Tab));
@@ -2856,7 +2905,7 @@ mod tests {
); );
assert!(first.is_none()); assert!(first.is_none());
assert!(matches!(second, Some(Method::Shutdown))); assert!(matches!(second, Some(Method::Shutdown { .. })));
assert_eq!(app.worker_status, WorkerStatus::Idle); assert_eq!(app.worker_status, WorkerStatus::Idle);
} }
@@ -2878,8 +2927,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 1, revision: 1,
event: Box::new(Event::Status { event: Box::new(Event::WorkerState {
status: WorkerStatus::Running, snapshot: WorkerStatus::Running.into(),
}), }),
}); });
@@ -2918,12 +2967,12 @@ mod tests {
type_keys(&mut app, "first"); type_keys(&mut app, "first");
assert!(matches!( assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)), handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. }) Some(Method::Submit { .. })
)); ));
type_keys(&mut app, "second"); type_keys(&mut app, "second");
assert!(matches!( assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)), handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. }) Some(Method::Submit { .. })
)); ));
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
@@ -2954,7 +3003,7 @@ mod tests {
type_keys(&mut app, "sent"); type_keys(&mut app, "sent");
assert!(matches!( assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)), handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. }) Some(Method::Submit { .. })
)); ));
type_keys(&mut app, "draft\nbody"); type_keys(&mut app, "draft\nbody");
app.move_cursor_start(); app.move_cursor_start();
+7
View File
@@ -1,5 +1,6 @@
mod app; mod app;
mod backend_dashboard; mod backend_dashboard;
mod backend_spawn;
mod backend_worker_picker; mod backend_worker_picker;
mod backend_workspace_picker; mod backend_workspace_picker;
mod block; mod block;
@@ -51,6 +52,8 @@ pub enum LaunchMode {
/// Restore one client-owned standalone Worker. The current cwd is the default scope; /// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// `include_all` opts into all standalone Workers under the same client data root. /// `include_all` opts into all standalone Workers under the same client data root.
StandaloneResume { include_all: bool }, StandaloneResume { include_all: bool },
/// Create one Backend Worker and attach to it.
BackendSpawn,
/// List Backend Workers and attach to the selected Worker. /// List Backend Workers and attach to the selected Worker.
Workers { Workers {
runtime_id: Option<String>, runtime_id: Option<String>,
@@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>), Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
} }
} }
LaunchMode::BackendSpawn => match target.launch_backend_worker() {
Ok(launch) => backend_spawn::run(launch.target).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Workers { LaunchMode::Workers {
runtime_id, runtime_id,
include_stopped, include_stopped,
+1 -6
View File
@@ -228,7 +228,7 @@ worker_context_max_tokens = 100000
enabled = true enabled = true
[feature.memory] [feature.memory]
enabled = true enabled = false
[feature.web] [feature.web]
enabled = true enabled = true
@@ -241,11 +241,6 @@ enabled = true
authoring = true authoring = true
thread = true thread = true
[memory]
extract_threshold = 50000
consolidation_threshold_files = 5
consolidation_threshold_bytes = 50000
[web] [web]
enabled = true enabled = true
+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> { fn run_status_line(app: &App, now: Instant) -> Line<'static> {
let elapsed = app let elapsed = if let Some(progress) = &app.compaction_progress {
.run_started_at let now_ms = std::time::SystemTime::now()
.and_then(|started_at| now.checked_duration_since(started_at)) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default(); .unwrap_or_default()
.as_millis() as u64;
std::time::Duration::from_millis(now_ms.saturating_sub(progress.started_at_ms))
} else {
app.run_started_at
.and_then(|started_at| now.checked_duration_since(started_at))
.unwrap_or_default()
};
let spinner_index = let spinner_index =
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len(); ((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
let request_label = if app.run_requests == 1 { let request_label = if app.run_requests == 1 {
@@ -151,7 +158,7 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
format!("{} reqs", app.run_requests) format!("{} reqs", app.run_requests)
}; };
Line::from(vec![ let mut spans = vec![
Span::styled( Span::styled(
RUN_SPINNER_FRAMES[spinner_index], RUN_SPINNER_FRAMES[spinner_index],
Style::default() Style::default()
@@ -159,6 +166,20 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" "), Span::raw(" "),
];
if let Some(progress) = &app.compaction_progress {
let phase = match progress.phase {
protocol::CompactionPhase::Preparing => "preparing",
protocol::CompactionPhase::Summarizing => "summarizing",
protocol::CompactionPhase::Committing => "committing",
};
spans.push(Span::styled(
format!("Compacting · {phase}"),
Style::default().fg(Color::Cyan),
));
spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
}
spans.extend([
Span::styled( Span::styled(
fmt_run_elapsed(elapsed.as_secs()), fmt_run_elapsed(elapsed.as_secs()),
Style::default().fg(Color::Gray), Style::default().fg(Color::Gray),
@@ -177,7 +198,8 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
fmt_tokens(app.run_output_tokens), fmt_tokens(app.run_output_tokens),
Style::default().fg(Color::Yellow), Style::default().fg(Color::Yellow),
), ),
]) ]);
Line::from(spans)
} }
fn fmt_run_elapsed(secs: u64) -> String { fn fmt_run_elapsed(secs: u64) -> String {
@@ -1880,7 +1902,7 @@ fn actionbar_left_item(app: &App, now: Instant) -> Option<(String, Style)> {
} }
if app.queued_input_count() > 0 { if app.queued_input_count() > 0 {
return Some(( return Some((
"Alt-q edit queued Alt-c clear queued".to_string(), "Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".to_string(),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} }
@@ -2136,9 +2158,25 @@ mod tests {
use super::*; use super::*;
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::block::{ToolCallBlock, ToolCallState}; use crate::block::{ToolCallBlock, ToolCallState};
use protocol::WorkerStatus; use protocol::Event;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
fn set_pending_submission(app: &mut App, id: &str) {
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 1,
notification_count: 0,
head_id: Some(id.into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: id.into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 1,
}],
},
});
}
#[test] #[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() { fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now(); let now = Instant::now();
@@ -2251,15 +2289,11 @@ mod tests {
#[test] #[test]
fn queue_status_text_includes_count_and_preview() { fn queue_status_text_includes_count_and_preview() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running); set_pending_submission(&mut app, "submission-1");
for c in "queued preview".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
assert_eq!( assert_eq!(
queue_status_text(&app), queue_status_text(&app),
Some("queued: 1 — queued preview".to_string()) Some("queued: 1 — submission-1".to_string())
); );
} }
@@ -2289,14 +2323,10 @@ mod tests {
Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into()) Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into())
); );
app.set_worker_status(WorkerStatus::Running); set_pending_submission(&mut app, "submission-1");
for c in "queued turn".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
assert_eq!( assert_eq!(
actionbar_left_item(&app, now).map(|(text, _)| text), actionbar_left_item(&app, now).map(|(text, _)| text),
Some("Alt-q edit queued Alt-c clear queued".into()) Some("Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".into())
); );
app.enter_command_mode(); app.enter_command_mode();
+1
View File
@@ -14,6 +14,7 @@ fs-operation.workspace = true
manifest.workspace = true manifest.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true
File diff suppressed because it is too large Load Diff
+82 -46
View File
@@ -11,7 +11,8 @@ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId, ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
WorkdirSessionCapabilities, WriteRequest, WriteResult, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
WriteRequest, WriteResult,
}; };
/// Opaque Runtime-owned identifier for one ephemeral Workdir session. /// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -55,6 +56,8 @@ pub struct OpenWorkdirSessionResponse {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "request", rename_all = "snake_case")] #[serde(tag = "operation", content = "request", rename_all = "snake_case")]
pub enum WorkdirSessionOperation { pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
Stat(StatRequest), Stat(StatRequest),
Read(ReadRequest), Read(ReadRequest),
Write(WriteRequest), Write(WriteRequest),
@@ -68,12 +71,10 @@ pub enum WorkdirSessionOperation {
CommandCancel(CommandHandle), CommandCancel(CommandHandle),
} }
/// Wire envelope for an operation and its optional provider-enforced child scope. /// Wire envelope for one provider operation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkdirSessionOperationRequest { pub struct WorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<crate::WorkdirDelegationRequest>,
pub operation: WorkdirSessionOperation, pub operation: WorkdirSessionOperation,
} }
@@ -81,6 +82,8 @@ pub struct WorkdirSessionOperationRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "result", rename_all = "snake_case")] #[serde(tag = "operation", content = "result", rename_all = "snake_case")]
pub enum WorkdirSessionOperationResult { pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
Stat(StatResult), Stat(StatResult),
Read(ReadResult), Read(ReadResult),
Write(WriteResult), Write(WriteResult),
@@ -171,7 +174,10 @@ impl WorkdirTransportError {
use WorkdirTransportErrorCode as Code; use WorkdirTransportErrorCode as Code;
let (code, message) = match error { let (code, message) = match error {
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"), WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
WorkdirError::Conflict(_) => (Code::Conflict, "Workdir content changed"), WorkdirError::Conflict(_) => (
Code::Conflict,
"The target file's content or existence changed since it was last observed; read the file again before retrying",
),
WorkdirError::Unsupported(capability) => { WorkdirError::Unsupported(capability) => {
return Self { return Self {
code: Code::Unsupported, code: Code::Unsupported,
@@ -289,13 +295,18 @@ mod client {
use reqwest::{Client, StatusCode, Url}; use reqwest::{Client, StatusCode, Url};
use super::*; use super::*;
use crate::{Workdir, WorkdirSession, WorkdirSessionHandle}; use crate::{Workdir, WorkdirSession};
/// Provides a fresh bearer token for each Runtime request. Backend /// Provides a fresh bearer token for each Runtime request. Backend
/// implementations can mint short-lived capability tokens without making a /// implementations can mint short-lived capability tokens without making a
/// Worker-bound session expire with the token used to open it. /// Worker-bound session expire with the token used to open it.
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync { pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
fn bearer_token(&self) -> Result<String, WorkdirError>; fn bearer_token(
&self,
method: &str,
path_and_query: &str,
body: &[u8],
) -> Result<String, WorkdirError>;
} }
struct FixedBearerToken(Arc<str>); struct FixedBearerToken(Arc<str>);
@@ -307,7 +318,12 @@ mod client {
} }
impl WorkdirHttpAuthorization for FixedBearerToken { impl WorkdirHttpAuthorization for FixedBearerToken {
fn bearer_token(&self) -> Result<String, WorkdirError> { fn bearer_token(
&self,
_method: &str,
_path_and_query: &str,
_body: &[u8],
) -> Result<String, WorkdirError> {
Ok(self.0.to_string()) Ok(self.0.to_string())
} }
} }
@@ -324,7 +340,6 @@ mod client {
workdir: Workdir, workdir: Workdir,
session_id: WorkdirSessionId, session_id: WorkdirSessionId,
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
delegations: Vec<crate::WorkdirDelegationRequest>,
closed: AtomicBool, closed: AtomicBool,
} }
@@ -357,10 +372,14 @@ mod client {
&base_url, &base_url,
&["v1", "working-directories", workdir_id.as_str(), "sessions"], &["v1", "working-directories", workdir_id.as_str(), "sessions"],
)?; )?;
let body = serde_json::to_vec(&request)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = authorization.bearer_token("POST", url.path(), &body)?;
let response = client let response = client
.post(url) .post(url)
.bearer_auth(authorization.bearer_token()?) .bearer_auth(token)
.json(&request) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -377,7 +396,6 @@ mod client {
workdir: Workdir::new(opened.workdir_id.as_str()), workdir: Workdir::new(opened.workdir_id.as_str()),
session_id: opened.session_id, session_id: opened.session_id,
capabilities: opened.capabilities, capabilities: opened.capabilities,
delegations: Vec::new(),
closed: AtomicBool::new(false), closed: AtomicBool::new(false),
}) })
} }
@@ -404,15 +422,16 @@ mod client {
"operations", "operations",
], ],
)?; )?;
let operation = WorkdirSessionOperationRequest { let operation = WorkdirSessionOperationRequest { operation };
delegations: self.delegations.clone(), let body = serde_json::to_vec(&operation)
operation, .map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
}; let token = self.authorization.bearer_token("POST", url.path(), &body)?;
let response = self let response = self
.client .client
.post(url) .post(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.json(&operation) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -436,35 +455,30 @@ mod client {
self.capabilities self.capabilities
} }
fn transports_delegation_context(&self) -> bool { async fn authorize_scope_path(
true &self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
match self
.operate(WorkdirSessionOperation::AuthorizeScope(request))
.await?
{
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
_ => Err(Self::mismatch("authorize_scope")),
}
} }
async fn capture_delegation_source( async fn scope_rules_overlap(
&self, &self,
request: &crate::WorkdirDelegationRequest, request: WorkdirScopeOverlapRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> { ) -> Result<bool, WorkdirError> {
if self.closed.load(Ordering::Acquire) { match self
return Err(WorkdirError::SessionClosed); .operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
.await?
{
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
_ => Err(Self::mismatch("scope_rules_overlap")),
} }
let mut delegations = self.delegations.clone();
delegations.push(request.clone());
let candidate = Arc::new(Self {
client: self.client.clone(),
base_url: self.base_url.clone(),
authorization: self.authorization.clone(),
workdir: self.workdir.clone(),
session_id: self.session_id.clone(),
capabilities: self.capabilities,
delegations,
closed: AtomicBool::new(false),
});
candidate
.stat(StatRequest {
path: fs_operation::FsPath::new("").expect("empty Workdir path is valid"),
})
.await?;
Ok(candidate)
} }
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
@@ -581,10 +595,11 @@ mod client {
&self.base_url, &self.base_url,
&["v1", "workdir-sessions", self.session_id.as_str()], &["v1", "workdir-sessions", self.session_id.as_str()],
)?; )?;
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
let response = self let response = self
.client .client
.delete(url) .delete(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -661,7 +676,7 @@ mod tests {
( (
WorkdirTransportErrorCode::Conflict, WorkdirTransportErrorCode::Conflict,
409, 409,
"modified externally", "The target file's content or existence changed since it was last observed",
), ),
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"), (WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
(WorkdirTransportErrorCode::Denied, 403, "denied"), (WorkdirTransportErrorCode::Denied, 403, "denied"),
@@ -703,7 +718,12 @@ mod tests {
] { ] {
let transport = WorkdirTransportError { let transport = WorkdirTransportError {
code, code,
message: "safe provider message".to_string(), message: if code == WorkdirTransportErrorCode::Conflict {
"The target file's content or existence changed since it was last observed; read the file again before retrying"
.to_string()
} else {
"safe provider message".to_string()
},
}; };
assert_eq!(code.http_status(), expected_status); assert_eq!(code.http_status(), expected_status);
let workdir_error = transport.clone().into_workdir_error(); let workdir_error = transport.clone().into_workdir_error();
@@ -770,5 +790,21 @@ mod tests {
transport.into_workdir_error(), transport.into_workdir_error(),
WorkdirError::Io { .. } WorkdirError::Io { .. }
)); ));
let error = WorkdirError::Conflict(
"The target file's content or existence changed since it was last observed; read the file again before retrying: /secret/runtime/root/file"
.to_string(),
);
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Conflict);
assert_eq!(
transport.message,
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
assert!(!transport.message.contains("/secret"));
assert_eq!(
transport.into_workdir_error().to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying"
);
} }
} }
+30 -33
View File
@@ -5,10 +5,10 @@
//! bound to one Worker. Tools consume sessions; they do not own Workdir //! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup. //! materialization or cleanup.
mod delegation;
pub mod http; pub mod http;
mod local; mod local;
mod operation; mod operation;
mod scope;
pub mod workspace; pub mod workspace;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -18,11 +18,6 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; use tokio::sync::broadcast;
pub use delegation::{
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
apply_delegation_chain, delegation_capable_session,
};
pub use fs_operation::{ pub use fs_operation::{
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
@@ -32,6 +27,11 @@ pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink, LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
}; };
pub use operation::*; pub use operation::*;
pub use scope::{
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
WorkdirToolScopeRule,
};
/// Persistent, opaque identity of one materialized Workdir. /// Persistent, opaque identity of one materialized Workdir.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -148,36 +148,31 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
fn workdir(&self) -> &Workdir; fn workdir(&self) -> &Workdir;
fn capabilities(&self) -> WorkdirSessionCapabilities; fn capabilities(&self) -> WorkdirSessionCapabilities;
fn is_delegation_capable(&self) -> bool { /// Validate an attenuated filesystem rule at the provider boundary without
false /// exposing the resolved host path. Providers that cannot resolve symbolic
} /// links must reject resolved-policy checks rather than downgrade them.
async fn authorize_scope_path(
/// Whether this session transports the delegation chain to another
/// provider boundary that will apply logical cwd/path resolution there.
fn transports_delegation_context(&self) -> bool {
false
}
/// Capture a provider-specific source for a delegated child session.
/// Remote providers use this boundary to pin attachment identity without
/// exposing transport handles or host paths.
async fn capture_delegation_source(
&self, &self,
_request: &WorkdirDelegationRequest, request: WorkdirScopeAuthorizationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> { ) -> Result<(), WorkdirError> {
Err(WorkdirError::Denied( if request.rules.iter().any(|rule| {
"workdir provider does not support delegated sessions".into(), 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 async fn scope_rules_overlap(
/// created with [`delegation_capable_session`] implement this operation.
async fn delegate(
&self, &self,
_request: WorkdirDelegationRequest, _request: WorkdirScopeOverlapRequest,
) -> Result<WorkdirDelegation, WorkdirError> { ) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied( Err(WorkdirError::Denied(
"workdir session is not delegation-capable".into(), "Workdir provider cannot compare resolved scope authority".to_string(),
)) ))
} }
@@ -240,7 +235,7 @@ pub enum WorkdirError {
#[error("Workdir transport failed: {0}")] #[error("Workdir transport failed: {0}")]
Transport(String), Transport(String),
#[error("Workdir content was modified externally before the operation could be applied: {0}")] #[error("{0}")]
Conflict(String), Conflict(String),
#[error("unknown Workdir session command: {0}")] #[error("unknown Workdir session command: {0}")]
@@ -354,7 +349,9 @@ impl From<fs_operation::FsError> for WorkdirError {
fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => { fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => {
Self::SymlinkTargetIsDirectory { path, target } Self::SymlinkTargetIsDirectory { path, target }
} }
fs_operation::FsError::Conflict(message) => Self::Conflict(message), fs_operation::FsError::Conflict(path) => Self::Conflict(format!(
"The target file's content or existence changed since it was last observed; read the file again before retrying: {path}"
)),
fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message), fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message),
fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message), fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message),
fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message), fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message),
+394 -89
View File
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait; use async_trait::async_trait;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope}; use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::{Mutex, broadcast, watch}; use tokio::sync::{Mutex, broadcast, watch};
@@ -28,9 +28,9 @@ use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
WriteResult, WriteResult,
}; };
#[cfg(test)] #[cfg(test)]
@@ -213,6 +213,52 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
fn is_writable(&self, path: &Path) -> bool { fn is_writable(&self, path: &Path) -> bool {
self.0.is_writable(path) self.0.is_writable(path)
} }
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
matches!(
self.0.permission_at_paths(logical, resolved),
Some(Permission::Read | Permission::Write)
)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
}
}
fn path_sets_overlap(
left: &Path,
left_recursive: bool,
right: &Path,
right_recursive: bool,
) -> bool {
match (left_recursive, right_recursive) {
(true, true) => left.starts_with(right) || right.starts_with(left),
(true, false) => {
right.starts_with(left)
|| left == right
|| left.parent().is_some_and(|parent| parent == right)
}
(false, true) => {
left.starts_with(right)
|| left == right
|| right.parent().is_some_and(|parent| parent == left)
}
(false, false) => {
left == right
|| left.parent().is_some_and(|parent| parent == right)
|| right.parent().is_some_and(|parent| parent == left)
}
}
}
fn rule_targets(
root: &Path,
rule: &crate::WorkdirToolScopeRule,
) -> std::io::Result<(PathBuf, PathBuf)> {
let logical = root.join(rule.target.as_str());
let resolved = fs_operation::resolve_access_path(&logical)?;
Ok((logical, resolved))
} }
#[derive(Debug)] #[derive(Debug)]
@@ -399,6 +445,11 @@ impl LocalWorkdirSession {
return Err(WorkdirError::RelativePath(path.to_path_buf())); return Err(WorkdirError::RelativePath(path.to_path_buf()));
} }
let symlink = first_symlink(path); let symlink = first_symlink(path);
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
return Err(broken_symlink_error(path, info));
}
let scope = self.inner.scope.load(); let scope = self.inner.scope.load();
if !scope.is_readable(path) { if !scope.is_readable(path) {
return Err(symlink_out_of_scope_or_plain( return Err(symlink_out_of_scope_or_plain(
@@ -408,11 +459,6 @@ impl LocalWorkdirSession {
&scope, &scope,
)); ));
} }
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(broken_symlink_error(path, info));
}
}
let meta = std::fs::metadata(path).map_err(|e| match e.kind() { let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()), std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
_ => WorkdirError::io(path, e), _ => WorkdirError::io(path, e),
@@ -558,67 +604,84 @@ impl WorkdirSession for LocalWorkdirSession {
self.inner.capabilities self.inner.capabilities
} }
async fn capture_delegation_source( async fn authorize_scope_path(
&self, &self,
request: &WorkdirDelegationRequest, request: WorkdirScopeAuthorizationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> { ) -> Result<(), WorkdirError> {
let host_rules = request self.ensure_open()?;
.rules let logical = self.inner.root.join(request.path.as_str());
.iter() let resolved = fs_operation::resolve_access_path(&logical)
.map(|rule| ScopeRule { .map_err(|error| WorkdirError::io(&logical, error))?;
target: self.inner.root.join(rule.target.as_str()), let parent_permission = self
permission: match rule.permission { .inner
WorkdirDelegationPermission::Read => Permission::Read, .scope
WorkdirDelegationPermission::Write => Permission::Write, .load()
}, .permission_at_paths(&logical, &resolved);
recursive: rule.recursive, let parent_allows = match request.permission {
}) WorkdirToolScopePermission::Read => matches!(
.collect::<Vec<_>>(); parent_permission,
for (logical, host) in request.rules.iter().zip(&host_rules) { Some(Permission::Read | Permission::Write)
if logical.permission == WorkdirDelegationPermission::Write { ),
let resolved = Scope::resolved_target(host) WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
.map_err(|error| WorkdirError::Denied(error.to_string()))?; };
if resolved != host.target { if !parent_allows {
return Err(WorkdirError::Denied(format!(
"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())
{
return Err(WorkdirError::Denied(format!( return Err(WorkdirError::Denied(format!(
"delegated cwd `{}` is not a readable Workdir directory", "Workdir path `{}` exceeds the provider attachment scope",
request.cwd request.path
))); )));
} }
Ok(Arc::new(LocalWorkdirSession::materialized_bound( let allowed = request.rules.iter().any(|rule| {
self.inner.workdir.clone(), if request.permission == WorkdirToolScopePermission::Write
self.inner.root.clone(), && rule.permission != WorkdirToolScopePermission::Write
self.inner.root.clone(), {
SharedScope::new(child_scope), return false;
self.inner.capabilities, }
))) let logical_target = self.inner.root.join(rule.target.as_str());
let (candidate, target) = match rule.symlink_policy {
SymlinkPolicy::Logical => (logical.as_path(), logical_target),
SymlinkPolicy::Resolved => {
let Ok(target) = fs_operation::resolve_access_path(&logical_target) else {
return false;
};
(resolved.as_path(), target)
}
};
if rule.recursive {
candidate.starts_with(target)
} else {
candidate == target || candidate.parent() == Some(target.as_path())
}
});
if allowed {
Ok(())
} else {
Err(WorkdirError::Denied(format!(
"Workdir path `{}` is outside the provider-resolved delegated scope",
request.path
)))
}
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.ensure_open()?;
let (left_logical, left_resolved) = rule_targets(&self.inner.root, &request.left)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
let (right_logical, right_resolved) = rule_targets(&self.inner.root, &request.right)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
Ok(path_sets_overlap(
&left_logical,
request.left.recursive,
&right_logical,
request.right.recursive,
) || path_sets_overlap(
&left_resolved,
request.left.recursive,
&right_resolved,
request.right.recursive,
))
} }
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
@@ -694,9 +757,20 @@ impl WorkdirSession for LocalWorkdirSession {
{ {
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf())); return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
} }
let cwd = if let Some(logical_cwd) = request.cwd.as_ref() {
let cwd = self.resolve(logical_cwd);
let scope = self.inner.scope.snapshot();
if !scope.is_readable(&cwd)
|| !std::fs::metadata(&cwd).is_ok_and(|metadata| metadata.is_dir())
{
return Err(WorkdirError::OutOfScope(cwd));
}
cwd
} else {
self.inner.cwd.clone()
};
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed); let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
let handle = CommandHandle(format!("command-{id}")); let handle = CommandHandle(format!("command-{id}"));
let cwd = self.inner.cwd.clone();
let (completion_tx, completion) = watch::channel(false); let (completion_tx, completion) = watch::channel(false);
let command_id = handle.0.clone(); let command_id = handle.0.clone();
let telemetry = self.inner.command_telemetry.clone(); let telemetry = self.inner.command_telemetry.clone();
@@ -1388,6 +1462,22 @@ mod tests {
) )
} }
fn make_logical_fs(dir: &TempDir) -> LocalWorkdirSession {
LocalWorkdirSession::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: Vec::new(),
})
.unwrap(),
dir.path().to_path_buf(),
)
}
#[tokio::test] #[tokio::test]
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() { async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1479,7 +1569,10 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(error, WorkdirError::Conflict(_))); assert_eq!(
error.to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying: notes/item.txt"
);
std::fs::remove_file(dir.path().join("notes/item.txt")).unwrap(); std::fs::remove_file(dir.path().join("notes/item.txt")).unwrap();
let error = WorkdirSession::write( let error = WorkdirSession::write(
@@ -1492,7 +1585,38 @@ mod tests {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(error, WorkdirError::Conflict(_))); assert_eq!(
error.to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying: notes/item.txt"
);
}
#[tokio::test]
async fn write_conflicts_when_observed_absence_becomes_a_file() {
let dir = TempDir::new().unwrap();
let workdir = make_fs(&dir);
let path = WorkdirPath::new("race.txt").unwrap();
let error = WorkdirSession::stat(&workdir, StatRequest { path: path.clone() })
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::NotFound(_)));
std::fs::write(dir.path().join("race.txt"), "created externally").unwrap();
let error = WorkdirSession::write(
&workdir,
WriteRequest {
path,
content: b"worker content".to_vec(),
expected_hash: None,
},
)
.await
.unwrap_err();
assert_eq!(
error.to_string(),
"The target file's content or existence changed since it was last observed; read the file again before retrying: race.txt"
);
} }
#[tokio::test] #[tokio::test]
@@ -1516,6 +1640,7 @@ mod tests {
command: "sleep 30".to_owned(), command: "sleep 30".to_owned(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -1586,6 +1711,102 @@ mod tests {
assert_eq!(read.bytes, b"persisted"); assert_eq!(read.bytes, b"persisted");
} }
#[cfg(unix)]
#[tokio::test]
async fn resolved_provider_scope_rejects_read_and_write_through_outside_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let target = outside.path().join("target.txt");
fs::write(&target, "secret").unwrap();
symlink(&target, root.path().join("alias.txt")).unwrap();
symlink(outside.path(), root.path().join("alias-dir")).unwrap();
let workdir = make_fs(&root);
assert!(matches!(
WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias.txt").unwrap(),
content: b"changed".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::SymlinkOutOfScope { .. })
));
assert_eq!(fs::read_to_string(target).unwrap(), "secret");
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias-dir/new.txt").unwrap(),
content: b"new".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("new.txt").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn resolved_deny_blocks_missing_write_through_logical_alias() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
symlink(outside.path(), root.path().join("alias")).unwrap();
let workdir = LocalWorkdirSession::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Logical,
}],
deny: vec![ScopeRule {
target: outside.path().join("blocked.txt"),
permission: Permission::Read,
recursive: false,
symlink_policy: SymlinkPolicy::Logical,
}],
})
.unwrap(),
root.path().to_path_buf(),
);
assert!(matches!(
WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("alias/blocked.txt").unwrap(),
content: b"blocked".to_vec(),
expected_hash: None,
}
)
.await,
Err(WorkdirError::ReadOnly(_))
));
assert!(!outside.path().join("blocked.txt").exists());
}
#[tokio::test] #[tokio::test]
async fn capability_boundary_rejects_direct_unsupported_operation() { async fn capability_boundary_rejects_direct_unsupported_operation() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1688,7 +1909,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn read_bytes_reports_symlink_target_outside_scope() { fn read_bytes_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1698,16 +1919,8 @@ mod tests {
let link = dir.path().join("outside-repo.txt"); let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_logical_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err(); assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
assert!(
matches!(
err,
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected symlink out-of-scope diagnostic, got {err:?}"
);
} }
#[cfg(unix)] #[cfg(unix)]
@@ -1799,7 +2012,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn write_reports_symlink_target_outside_scope() { fn write_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1809,15 +2022,14 @@ mod tests {
let link = dir.path().join("outside-repo.txt"); let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_logical_fs(&dir);
let err = fs.write(&link, b"new").unwrap_err(); fs.write(&link, b"new").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!( assert!(
matches!( fs::symlink_metadata(&link)
err, .unwrap()
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" } .file_type()
if path == &link && err_target == &target.canonicalize().unwrap() .is_symlink()
),
"expected write symlink out-of-scope diagnostic, got {err:?}"
); );
} }
@@ -1840,11 +2052,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: vec![ScopeRule { deny: vec![ScopeRule {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
}; };
let scope = Scope::from_config(&cfg).unwrap(); let scope = Scope::from_config(&cfg).unwrap();
@@ -1908,6 +2122,7 @@ mod tests {
target: extra.path().to_path_buf(), target: extra.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1944,6 +2159,7 @@ mod tests {
target: sub.clone(), target: sub.clone(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1980,6 +2196,7 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}]) }])
}) })
.unwrap(); .unwrap();
@@ -1995,6 +2212,83 @@ mod tests {
)); ));
} }
#[cfg(unix)]
#[tokio::test]
async fn provider_uses_explicit_logical_policy_through_symlinked_directories() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
let workdir = make_logical_fs(&dir);
let read = WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("yoi.local/worker.json").unwrap(),
offset: 0,
limit: 100,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(read.bytes, b"scope-needle\n");
let list = WorkdirSession::list(
&workdir,
ListRequest {
path: WorkdirPath::new("yoi.local").unwrap(),
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
list.entries[0].path,
WorkdirPath::new("yoi.local/worker.json").unwrap()
);
let glob = WorkdirSession::glob(
&workdir,
GlobRequest {
pattern: "**/*.json".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
glob.paths,
[WorkdirPath::new("yoi.local/worker.json").unwrap()]
);
let grep = WorkdirSession::grep(
&workdir,
GrepRequest {
pattern: "scope-needle".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
glob: Some("*.json".into()),
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: crate::GrepOutputMode::Content,
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(grep.match_count, 1);
assert!(grep.output.contains("yoi.local/worker.json"));
assert!(
!workdir
.scope()
.is_readable(&outside.path().join("worker.json"))
);
}
#[tokio::test] #[tokio::test]
async fn provider_executes_glob_grep_and_command_at_the_materialization() { async fn provider_executes_glob_grep_and_command_at_the_materialization() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -2043,6 +2337,7 @@ mod tests {
command: "pwd && printf provider-command".into(), command: "pwd && printf provider-command".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 4096, output_limit: 4096,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -2081,11 +2376,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -2141,6 +2438,7 @@ mod tests {
command: "printf hidden".into(), command: "printf hidden".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1, output_limit: 1,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()), spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None, tool_call_id: None,
}, },
@@ -2161,11 +2459,13 @@ mod tests {
target: dir.path().to_path_buf(), target: dir.path().to_path_buf(),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
ScopeRule { ScopeRule {
target: spill.path().to_path_buf(), target: spill.path().to_path_buf(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}, },
], ],
deny: Vec::new(), deny: Vec::new(),
@@ -2178,6 +2478,7 @@ mod tests {
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(), command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 64, output_limit: 64,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()), spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None, tool_call_id: None,
}, },
@@ -2224,6 +2525,7 @@ mod tests {
command: "printf 'aéz'".into(), command: "printf 'aéz'".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -2449,6 +2751,7 @@ mod tests {
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: Some("tool-7".into()), tool_call_id: Some("tool-7".into()),
}, },
@@ -2553,6 +2856,7 @@ mod tests {
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 1, timeout_secs: 1,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
@@ -2623,6 +2927,7 @@ mod tests {
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024, output_limit: 1024,
cwd: None,
spill_dir: None, spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
+4
View File
@@ -11,6 +11,10 @@ pub struct CommandRequest {
pub command: String, pub command: String,
pub timeout_secs: u64, pub timeout_secs: u64,
pub output_limit: usize, pub output_limit: usize,
/// Workdir-relative command directory. Providers validate it against the
/// active session before process start.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<fs_operation::FsPath>,
/// Provider-local directory where complete output is retained when the /// Provider-local directory where complete output is retained when the
/// inline result exceeds `output_limit`. /// inline result exceeds `output_limit`.
pub spill_dir: Option<PathBuf>, pub spill_dir: Option<PathBuf>,
File diff suppressed because it is too large Load Diff
-10
View File
@@ -104,15 +104,5 @@ mod tests {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionOperationRequest { pub struct WorkspaceWorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_session_fence: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<crate::WorkdirDelegationRequest>,
pub operation: crate::http::WorkdirSessionOperation, pub operation: crate::http::WorkdirSessionOperation,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionFence {
pub value: String,
}
+5 -2
View File
@@ -18,7 +18,7 @@ required-features = ["ws-server", "fs-store"]
[features] [features]
default = ["ws-server", "fs-store"] default = ["ws-server", "fs-store"]
fs-store = [] fs-store = ["dep:fs4"]
http-server = ["dep:axum", "dep:tower", "dep:reqwest"] http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
@@ -29,6 +29,7 @@ axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true } futures = { workspace = true, optional = true }
decodal.workspace = true decodal.workspace = true
flow = { path = "../flow" } flow = { path = "../flow" }
fs4 = { workspace = true, optional = true }
manifest.workspace = true manifest.workspace = true
protocol.workspace = true protocol.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
@@ -39,7 +40,9 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
ring.workspace = true ring.workspace = true
tar.workspace = true tar.workspace = true
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] }
tracing.workspace = true
tracing-subscriber.workspace = true
toml.workspace = true toml.workspace = true
url.workspace = true url.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
+87 -220
View File
@@ -2,6 +2,7 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fmt; use std::fmt;
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:"; const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:"; const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
const TOKEN_PREFIX: &str = "yoi-cap-v1";
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof"; pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1"; const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1."; const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
WrongMutationTarget, WrongMutationTarget,
} }
pub(crate) struct SignedJsonToken<T> {
pub payload: String,
pub signature: Vec<u8>,
pub claims: T,
}
pub(crate) fn sign_json_token<T: Serialize>(
token_prefix: &str,
signing_input_prefix: &str,
signing_key: &Ed25519KeyPair,
claims: &T,
) -> Result<String, RuntimeAuthError> {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
let signing_input = format!("{signing_input_prefix}{payload}");
let signature = signing_key.sign(signing_input.as_bytes());
Ok(format!(
"{token_prefix}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
pub(crate) fn decode_signed_json_token<T: DeserializeOwned>(
token: &str,
expected_prefix: &str,
) -> Result<SignedJsonToken<T>, RuntimeAuthError> {
let (prefix, payload, signature) = split_three_part_token(token)?;
if prefix != expected_prefix {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
Ok(SignedJsonToken {
payload: payload.to_string(),
signature,
claims,
})
}
pub(crate) fn verify_signed_json_token(
signing_input_prefix: &str,
payload: &str,
signature: &[u8],
public_key: &str,
) -> Result<(), RuntimeAuthError> {
let public_key = decode_public_key(public_key)?;
let signing_input = format!("{signing_input_prefix}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)
}
fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = token.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
pub(crate) fn is_request_body_digest(value: &str) -> bool {
URL_SAFE_NO_PAD
.decode(value)
.is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeIdentityMaterial { pub struct RuntimeIdentityMaterial {
pub identity_id: String, pub identity_id: String,
@@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedServerKey {
pub server_id: String,
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpAuthConfig {
pub runtime_id: String,
#[serde(default)]
pub trusted_servers: Vec<TrustedServerKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthContext { pub struct RuntimeAuthContext {
pub server_id: String, pub server_id: String,
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
pub expires_at: u64, pub expires_at: u64,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
pub permissions: Vec<String>,
pub exp: u64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapabilityTokenSigner {
server_id: String,
private_key: String,
}
impl CapabilityTokenSigner {
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
Self {
server_id: server_id.into(),
private_key: private_key.into(),
}
}
pub fn server_id(&self) -> &str {
&self.server_id
}
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
if claims.iss != self.server_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
}
let private = decode_private_key(&self.private_key)?;
let pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let payload = serde_json::to_vec(claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
let signature = pair.sign(signing_input.as_bytes());
Ok(format!(
"{TOKEN_PREFIX}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
}
pub fn capability_claims(
server_id: impl Into<String>,
runtime_id: impl Into<String>,
workspace_id: impl Into<String>,
permissions: Vec<String>,
ttl_seconds: u64,
) -> Result<CapabilityClaims, RuntimeAuthError> {
let exp = unix_now_seconds().saturating_add(ttl_seconds);
Ok(CapabilityClaims {
iss: server_id.into(),
aud: runtime_id.into(),
workspace_id: workspace_id.into(),
permissions,
exp,
jti: new_token_id()?,
})
}
pub fn verify_capability_token(
config: &RuntimeHttpAuthConfig,
token: &str,
required_permission: Option<&str>,
now_seconds: u64,
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
let (payload, signature) = split_token(token)?;
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
let Some(server) = config
.trusted_servers
.iter()
.find(|server| server.server_id == claims.iss)
else {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
};
let public_key = decode_public_key(&server.public_key)?;
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
if claims.aud != config.runtime_id {
return Err(RuntimeAuthError::WrongAudience {
expected: config.runtime_id.clone(),
actual: claims.aud,
});
}
if claims.exp < now_seconds {
return Err(RuntimeAuthError::Expired);
}
if claims.workspace_id.trim().is_empty() {
return Err(RuntimeAuthError::MissingWorkspaceScope);
}
if let Some(required) = required_permission {
if !claims
.permissions
.iter()
.any(|permission| permission == required)
{
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
}
}
Ok(RuntimeAuthContext {
server_id: claims.iss,
workspace_id: claims.workspace_id,
permissions: claims.permissions,
token_id: claims.jti,
expires_at: claims.exp,
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims { pub struct RuntimeRequestSourceClaims {
pub iss: String, pub iss: String,
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
exp: now_unix.saturating_add(ttl_seconds), exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?, jti: new_token_id()?,
}; };
let payload = serde_json::to_vec(&claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let private = decode_private_key(&self.private_key)?; let private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private) let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref()); sign_json_token(
Ok(format!( RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}" RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
)) &key_pair,
&claims,
)
} }
} }
pub fn decode_runtime_request_source_claims( pub fn decode_runtime_request_source_claims(
proof: &str, proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?; Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
} }
pub fn verify_runtime_request_source( pub fn verify_runtime_request_source(
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
public_key: &str, public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>, expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?; let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { proof,
return Err(RuntimeAuthError::InvalidTokenFormat); RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
} )?;
let signature = URL_SAFE_NO_PAD.decode(signature)?; verify_signed_json_token(
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
let public_key = decode_public_key(public_key)?; &signed.payload,
UnparsedPublicKey::new(&ED25519, public_key) &signed.signature,
.verify(signing_input.as_bytes(), &signature) public_key,
.map_err(|_| RuntimeAuthError::InvalidSignature)?; )?;
let claims = decode_runtime_request_source_claims(proof)?; let claims = signed.claims;
if claims.iss != expected.identity_id if claims.iss != expected.identity_id
|| claims.aud != expected.audience || claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id || claims.workspace_id != expected.workspace_id
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
Ok(claims) Ok(claims)
} }
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims { pub struct WorkerMutationSourceClaims {
pub iss: String, pub iss: String,
@@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
} }
} }
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
}
_ => Err(RuntimeAuthError::InvalidTokenFormat),
}
}
pub fn encode_public_key(bytes: &[u8]) -> String { pub fn encode_public_key(bytes: &[u8]) -> String {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)) format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
} }
@@ -851,46 +760,4 @@ mod tests {
Err(RuntimeAuthError::Expired) Err(RuntimeAuthError::Expired)
)); ));
} }
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
let claims = CapabilityClaims {
iss: "server-main".to_string(),
aud: "runtime-main".to_string(),
workspace_id: "workspace-a".to_string(),
permissions: vec!["workers:list".to_string()],
exp: 100,
jti: "token-1".to_string(),
};
let token = signer.sign(&claims).unwrap();
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-main".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: "server-main".to_string(),
public_key: server.public_key.clone(),
display_name: None,
}],
};
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
assert_eq!(context.workspace_id, "workspace-a");
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:create"), 99),
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
));
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:list"), 101),
Err(RuntimeAuthError::Expired)
));
let wrong_audience = RuntimeHttpAuthConfig {
runtime_id: "other-runtime".to_string(),
trusted_servers: auth.trusted_servers.clone(),
};
assert!(matches!(
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
Err(RuntimeAuthError::WrongAudience { .. })
));
}
} }
+34 -25
View File
@@ -15,32 +15,22 @@ pub enum ProfileSelector {
Named(String), Named(String),
} }
/// Runtime fetch/caching metadata for a Backend-authored Decodal profile source archive.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileSourceArchiveHttpRef {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
pub archive: ProfileSourceArchiveRef,
}
/// Profile source material available to a Runtime during Worker creation. /// Profile source material available to a Runtime during Worker creation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSourceArchiveSource { pub enum ProfileSourceArchiveSource {
/// Backend-internal embedded runtimes may receive already-built archive bytes. /// Backend-internal embedded runtimes may receive already-built archive bytes.
Embedded { archive: ProfileSourceArchive }, Embedded { archive: ProfileSourceArchive },
/// Standalone runtimes fetch/cache the tar archive over HTTP. /// Standalone runtimes resolve this immutable archive from the latest
Http { /// Workspace Config bundle before creating the Worker.
location: ProfileSourceArchiveHttpRef, WorkspaceConfig { archive: ProfileSourceArchiveRef },
},
} }
impl ProfileSourceArchiveSource { impl ProfileSourceArchiveSource {
pub fn reference(&self) -> ProfileSourceArchiveRef { pub fn reference(&self) -> ProfileSourceArchiveRef {
match self { match self {
Self::Embedded { archive } => archive.reference.clone(), Self::Embedded { archive } => archive.reference.clone(),
Self::Http { location } => location.archive.clone(), Self::WorkspaceConfig { archive } => archive.clone(),
} }
} }
} }
@@ -129,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess { pub struct RepositorySshCredentialCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, pub credential_revision: u64,
#[serde(skip, default)]
pub private_key: SensitiveString,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String, pub host_trust_id: String,
pub host_trust_revision: u64, pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode, pub access: workspace_api::RepositoryAccessMode,
@@ -141,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
pub repository_uri: String, pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle, pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)] #[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString, pub known_hosts_entry: SensitiveString,
} }
@@ -153,8 +148,6 @@ pub struct RepositoryMaterializationContext {
pub operation_id: String, pub operation_id: String,
pub config_revision: u64, pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>, pub ssh: Option<RepositorySshMaterializationAccess>,
} }
@@ -273,11 +266,11 @@ pub struct CreateWorkerRequest {
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>, pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
} }
/// Worker lifecycle status for the in-memory embedded runtime. /// Last persisted Worker lifecycle status.
/// ///
/// Run termination details are carried separately by the Worker protocol. In /// This is not proof that the current Runtime process holds a live execution handle. Run
/// particular, cancellation returns a Worker to `Idle`; it is not a lifecycle /// termination details remain separate Worker protocol state; in particular, cancellation
/// state of its own. /// returns a Worker to `Idle` and is not a lifecycle state of its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerStatus { pub enum WorkerStatus {
@@ -300,12 +293,19 @@ pub(crate) enum WorkerRestoreIntent {
Explicit, Explicit,
} }
/// Lightweight catalog row. /// Lightweight persisted Worker identity projection.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSummary { pub struct WorkerSummary {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
/// Creation timestamp in Unix epoch milliseconds for records created on this schema.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at_ms: Option<u64>,
/// Whether the persisted execution metadata was valid when this identity was loaded.
pub execution_metadata_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -318,12 +318,19 @@ pub struct WorkerSummary {
pub config_bundle: Option<ConfigBundleRef>, pub config_bundle: Option<ConfigBundleRef>,
} }
/// Full Worker catalog/lifecycle detail. /// Full persisted Worker identity and lifecycle detail.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerDetail { pub struct WorkerDetail {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
/// Creation timestamp in Unix epoch milliseconds for records created on this schema.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at_ms: Option<u64>,
/// Whether the persisted execution metadata was valid when this identity was loaded.
pub execution_metadata_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -341,6 +348,8 @@ pub struct WorkerDetail {
pub struct WorkerLifecycleAck { pub struct WorkerLifecycleAck {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
} }
#[cfg(test)] #[cfg(test)]
@@ -9,6 +9,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256"; pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256";
pub const WORKSPACE_CONFIG_ETAG_PREFIX: &str = "workspace-config:";
pub fn workspace_config_etag(digest: &str) -> String {
format!("\"{WORKSPACE_CONFIG_ETAG_PREFIX}{digest}\"")
}
/// Backend-synced Profile/config bundle stored by a Runtime. /// Backend-synced Profile/config bundle stored by a Runtime.
/// ///
+9
View File
@@ -21,6 +21,12 @@ pub enum RuntimeError {
message: String, message: String,
}, },
#[error("worker {worker_id} metadata deletion failed: {message}")]
WorkerDeletePersistenceFailed {
worker_id: WorkerId,
message: String,
},
#[error("worker creation has no execution backend: {message}")] #[error("worker creation has no execution backend: {message}")]
ExecutionBackendUnavailable { message: String }, ExecutionBackendUnavailable { message: String },
@@ -79,6 +85,9 @@ pub enum RuntimeError {
name: String, name: String,
}, },
#[error("Runtime store is already owned by another process")]
RuntimeStoreAlreadyOpen { path: PathBuf },
#[error("runtime store {operation} failed at {}: {source}", path.display())] #[error("runtime store {operation} failed at {}: {source}", path.display())]
StoreIo { StoreIo {
operation: &'static str, operation: &'static str,
+70 -49
View File
@@ -1,6 +1,7 @@
use crate::catalog::{ use crate::catalog::{
RepositoryRefObservation, RepositoryRefObservationRequest, ConfigBundleRef, ProfileSelector, RepositoryRefObservation, RepositoryRefObservationRequest,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
}; };
use crate::config_bundle::ConfigBundle; use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError; use crate::error::RuntimeError;
@@ -15,18 +16,6 @@ use std::fmt;
use std::sync::Arc; use std::sync::Arc;
use workdir::WorkdirSessionHandle; use workdir::WorkdirSessionHandle;
/// Current execution-side run state for a Worker.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionRunState {
#[default]
Stopped,
Idle,
Busy,
Rejected,
Errored,
}
/// Execution operation that produced a result. /// Execution operation that produced a result.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -41,14 +30,12 @@ pub enum WorkerExecutionOperation {
Cancel, Cancel,
} }
/// Evidence that a user input reached the durable Worker session boundary. /// Evidence that a Submit request reached the durable Worker session boundary.
///
/// This is intentionally distinct from accepting a method on the Worker's
/// in-memory channel. For Flow submissions, the committed UserInput entry also
/// carries the initial Flow runtime-state extension.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerInputCommitAck { pub struct WorkerSubmissionAck {
pub submission_request_id: String,
pub submission_id: String, pub submission_id: String,
pub disposition: protocol::SubmissionDisposition,
} }
/// Typed execution result class. Results are transient operation outcomes and /// Typed execution result class. Results are transient operation outcomes and
@@ -57,11 +44,12 @@ pub struct WorkerInputCommitAck {
pub struct WorkerExecutionResult { pub struct WorkerExecutionResult {
pub operation: WorkerExecutionOperation, pub operation: WorkerExecutionOperation,
pub outcome: WorkerExecutionOutcome, pub outcome: WorkerExecutionOutcome,
pub run_state: WorkerExecutionRunState, #[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>, pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub input_commit: Option<WorkerInputCommitAck>, pub submission: Option<WorkerSubmissionAck>,
} }
/// Backend result class for a Worker execution operation. /// Backend result class for a Worker execution operation.
@@ -76,31 +64,36 @@ pub enum WorkerExecutionOutcome {
} }
impl WorkerExecutionResult { impl WorkerExecutionResult {
pub fn accepted( pub fn accepted(operation: WorkerExecutionOperation) -> Self {
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
) -> Self {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Accepted, outcome: WorkerExecutionOutcome::Accepted,
run_state, worker_state: None,
message: None, message: None,
input_commit: None, submission: None,
} }
} }
pub fn accepted_input_committed( pub fn with_worker_state(mut self, worker_state: protocol::WorkerStateSnapshot) -> Self {
self.worker_state = Some(worker_state);
self
}
pub fn accepted_submission(
operation: WorkerExecutionOperation, operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState, submission_request_id: impl Into<String>,
submission_id: impl Into<String>, submission_id: impl Into<String>,
disposition: protocol::SubmissionDisposition,
) -> Self { ) -> Self {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Accepted, outcome: WorkerExecutionOutcome::Accepted,
run_state, worker_state: None,
message: None, message: None,
input_commit: Some(WorkerInputCommitAck { submission: Some(WorkerSubmissionAck {
submission_request_id: submission_request_id.into(),
submission_id: submission_id.into(), submission_id: submission_id.into(),
disposition,
}), }),
} }
} }
@@ -109,9 +102,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Busy, outcome: WorkerExecutionOutcome::Busy,
run_state: WorkerExecutionRunState::Busy, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -119,9 +112,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Rejected, outcome: WorkerExecutionOutcome::Rejected,
run_state: WorkerExecutionRunState::Stopped, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -129,9 +122,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Errored, outcome: WorkerExecutionOutcome::Errored,
run_state: WorkerExecutionRunState::Errored, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -139,9 +132,9 @@ impl WorkerExecutionResult {
Self { Self {
operation, operation,
outcome: WorkerExecutionOutcome::Unsupported, outcome: WorkerExecutionOutcome::Unsupported,
run_state: WorkerExecutionRunState::Stopped, worker_state: None,
message: Some(message.into()), message: Some(message.into()),
input_commit: None, submission: None,
} }
} }
@@ -250,8 +243,6 @@ impl fmt::Debug for WorkerExecutionContext {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkerExecutionSpawnRequest { pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
/// Monotonic execution generation reserved durably before launch.
pub run_generation: u64,
pub request: crate::catalog::CreateWorkerRequest, pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>, pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
@@ -263,8 +254,6 @@ pub struct WorkerExecutionSpawnRequest {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkerExecutionRestoreRequest { pub struct WorkerExecutionRestoreRequest {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
/// Monotonic execution generation reserved durably before restore.
pub run_generation: u64,
pub request: crate::catalog::CreateWorkerRequest, pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>, pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
@@ -273,12 +262,28 @@ pub struct WorkerExecutionRestoreRequest {
pub config_bundle: Option<ConfigBundle>, pub config_bundle: Option<ConfigBundle>,
} }
/// Runtime-side request to refresh the latest Workspace Config before Worker creation.
#[derive(Clone, Debug)]
pub struct WorkspaceConfigFetchRequest {
pub workspace_api: WorkspaceApiRef,
pub profile: ProfileSelector,
pub expected: ConfigBundleRef,
pub cached: Option<ConfigBundleRef>,
}
/// Result of a conditional Workspace Config fetch.
#[derive(Clone, Debug)]
pub enum WorkspaceConfigFetchResult {
NotModified,
Modified(ConfigBundle),
}
/// Backend outcome for Worker spawn/restore operations. /// Backend outcome for Worker spawn/restore operations.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum WorkerExecutionSpawnResult { pub enum WorkerExecutionSpawnResult {
Connected { Connected {
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState, worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>, working_directory: Option<WorkingDirectoryStatus>,
}, },
Rejected(WorkerExecutionResult), Rejected(WorkerExecutionResult),
@@ -288,12 +293,12 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult { impl WorkerExecutionSpawnResult {
pub fn connected( pub fn connected(
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState, worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>, working_directory: Option<WorkingDirectoryStatus>,
) -> Self { ) -> Self {
Self::Connected { Self::Connected {
handle, handle,
run_state, worker_state,
working_directory, working_directory,
} }
} }
@@ -302,6 +307,13 @@ impl WorkerExecutionSpawnResult {
pub trait WorkerExecutionBackend: Send + Sync + 'static { pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str; fn backend_id(&self) -> &str;
fn fetch_workspace_config(
&self,
_request: WorkspaceConfigFetchRequest,
) -> Result<WorkspaceConfigFetchResult, String> {
Err("execution backend does not support Workspace Config fetching".to_string())
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult; fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
fn restore_worker( fn restore_worker(
@@ -483,6 +495,13 @@ impl WorkerExecutionBackendRef {
}) })
} }
pub(crate) fn fetch_workspace_config(
&self,
request: WorkspaceConfigFetchRequest,
) -> Result<WorkspaceConfigFetchResult, String> {
self.backend.fetch_workspace_config(request)
}
pub(crate) fn spawn_worker( pub(crate) fn spawn_worker(
&self, &self,
request: WorkerExecutionSpawnRequest, request: WorkerExecutionSpawnRequest,
@@ -618,14 +637,16 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn input_commit_ack_survives_json_round_trip() { fn submission_ack_survives_json_round_trip() {
let result = WorkerExecutionResult::accepted_input_committed( let result = WorkerExecutionResult::accepted_submission(
WorkerExecutionOperation::Input, WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy, "request-1",
"submission-1", "submission-1",
protocol::SubmissionDisposition::Started,
); );
let json = serde_json::to_string(&result).unwrap(); let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"submission_request_id\":\"request-1\""));
assert!(json.contains("\"submission_id\":\"submission-1\"")); assert!(json.contains("\"submission_id\":\"submission-1\""));
assert_eq!( assert_eq!(
serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(), serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -25,10 +25,10 @@ impl WorkerInputKind {
pub struct WorkerInput { pub struct WorkerInput {
pub kind: WorkerInputKind, pub kind: WorkerInputKind,
pub content: String, pub content: String,
/// Runtime-generated correlation id. This is never accepted from public /// Authenticated client-generated idempotency key. Runtime generates one
/// JSON input and is consumed only by the execution backend. /// only for trusted internal callers that omit it.
#[serde(skip)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_id: Option<String>, pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<Segment>>, pub segments: Option<Vec<Segment>>,
} }
@@ -38,7 +38,7 @@ impl WorkerInput {
Self { Self {
kind: WorkerInputKind::User, kind: WorkerInputKind::User,
content: content.into(), content: content.into(),
submission_id: None, submission_request_id: None,
segments: None, segments: None,
} }
} }
@@ -47,7 +47,7 @@ impl WorkerInput {
Self { Self {
kind: WorkerInputKind::Notify, kind: WorkerInputKind::Notify,
content: content.into(), content: content.into(),
submission_id: None, submission_request_id: None,
segments: None, segments: None,
} }
} }
@@ -57,6 +57,21 @@ impl WorkerInput {
mod tests { mod tests {
use super::WorkerInput; use super::WorkerInput;
#[test]
fn submission_request_id_round_trips_for_authenticated_client_retry() {
let input: WorkerInput = serde_json::from_value(serde_json::json!({
"kind": "user",
"content": "message",
"submission_request_id": "request-1"
}))
.unwrap();
assert_eq!(input.submission_request_id.as_deref(), Some("request-1"));
assert_eq!(
serde_json::to_value(input).unwrap()["submission_request_id"],
"request-1"
);
}
#[test] #[test]
fn notify_is_an_operation_and_legacy_system_kind_is_rejected() { fn notify_is_an_operation_and_legacy_system_kind_is_rejected() {
assert_eq!( assert_eq!(
@@ -78,4 +93,7 @@ mod tests {
pub struct WorkerInteractionAck { pub struct WorkerInteractionAck {
pub worker_ref: WorkerRef, pub worker_ref: WorkerRef,
pub status: WorkerStatus, pub status: WorkerStatus,
/// Present for User Submit and absent for non-Submit interactions.
#[serde(skip_serializing_if = "Option::is_none")]
pub submission: Option<crate::execution::WorkerSubmissionAck>,
} }
+4
View File
@@ -25,9 +25,13 @@ pub mod resource;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub mod retention; pub mod retention;
mod runtime; mod runtime;
pub mod ssh_host_key_probe;
pub mod worker_backend; pub mod worker_backend;
pub mod worker_source; pub mod worker_source;
pub mod working_directory; pub mod working_directory;
pub mod workspace_issuer;
#[cfg(feature = "http-server")]
pub mod workspace_request;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
File diff suppressed because it is too large Load Diff
+159 -81
View File
@@ -1,13 +1,9 @@
use crate::auth::{ use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION;
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::identity::WorkerId; use crate::identity::WorkerId;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex}; use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str = pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar"; "application/vnd.yoi.profile-source-archive+tar";
@@ -15,16 +11,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json"; "application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024; pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15);
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecretCandidate {
pub credential_id: String,
pub credential_revision: u64,
pub private_key: String,
}
impl Drop for RepositorySshAccessSecretCandidate {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
}
}
impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecretCandidate")
.field("credential_id", &self.credential_id)
.field("credential_revision", &self.credential_revision)
.field("private_key", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret { pub struct RepositorySshAccessSecret {
pub private_key: String, pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
pub known_hosts_entry: String, pub known_hosts_entry: String,
} }
impl Drop for RepositorySshAccessSecret { impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) { fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry); zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
} }
} }
@@ -33,7 +54,7 @@ impl std::fmt::Debug for RepositorySshAccessSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter formatter
.debug_struct("RepositorySshAccessSecret") .debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]") .field("credential_candidates", &self.credential_candidates)
.field("known_hosts_entry", &"[REDACTED]") .field("known_hosts_entry", &"[REDACTED]")
.finish() .finish()
} }
@@ -144,6 +165,8 @@ pub enum BackendResourceError {
Oversized { max_bytes: u64, actual_bytes: u64 }, Oversized { max_bytes: u64, actual_bytes: u64 },
#[error("backend resource content type mismatch: expected {expected}, got {actual}")] #[error("backend resource content type mismatch: expected {expected}, got {actual}")]
ContentTypeMismatch { expected: String, actual: String }, ContentTypeMismatch { expected: String, actual: String },
#[error("backend resource fetch timed out")]
Timeout,
#[error("backend resource transport failed: {message}")] #[error("backend resource transport failed: {message}")]
Transport { message: String }, Transport { message: String },
#[error("backend resource response is invalid: {message}")] #[error("backend resource response is invalid: {message}")]
@@ -163,9 +186,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
pub struct HttpBackendResourceClient { pub struct HttpBackendResourceClient {
endpoint: String, endpoint: String,
bearer_token: Option<String>, bearer_token: Option<String>,
request_source_signer: Option<RuntimeRequestSourceSigner>, workspace_request_client: Option<RuntimeWorkspaceRequestClient>,
request_source_audience: Option<String>, request_timeout: std::time::Duration,
client: reqwest::Client,
} }
#[cfg(feature = "http-server")] #[cfg(feature = "http-server")]
@@ -174,19 +196,18 @@ impl HttpBackendResourceClient {
Self { Self {
endpoint: endpoint.into(), endpoint: endpoint.into(),
bearer_token, bearer_token,
request_source_signer: None, workspace_request_client: None,
request_source_audience: None, request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
client: reqwest::Client::new(),
} }
} }
pub fn with_runtime_request_source( pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
mut self, self.request_timeout = timeout;
identity: &RuntimeIdentityMaterial, self
audience: impl Into<String>, }
) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity)); pub fn with_workspace_request_client(mut self, client: RuntimeWorkspaceRequestClient) -> Self {
self.request_source_audience = Some(audience.into()); self.workspace_request_client = Some(client);
self self
} }
} }
@@ -208,55 +229,73 @@ impl BackendResourceClient for HttpBackendResourceClient {
message: error.to_string(), message: error.to_string(),
} }
})?; })?;
let mut builder = self let client = self.workspace_request_client.as_ref().ok_or_else(|| {
.client BackendResourceError::Unauthorized {
.post(endpoint.clone()) message: "Workspace request client is unavailable".to_string(),
.header(reqwest::header::CONTENT_TYPE, "application/json") }
.body(body.clone()); })?;
if let Some(signer) = self.request_source_signer.as_ref() { if client.workspace_id() != request.handle.workspace_id {
let audience = self.request_source_audience.as_deref().ok_or_else(|| { return Err(BackendResourceError::Unauthorized {
BackendResourceError::Unauthorized { message: "Workspace request client does not match the resource workspace"
message: "Runtime request proof audience is unavailable".to_owned(), .to_string(),
} });
})?; }
let proof = signer let base_url = client.base_url().trim_end_matches('/');
.issue( let endpoint_text = endpoint.as_str();
audience, let endpoint_suffix = endpoint_text.strip_prefix(base_url).ok_or_else(|| {
&request.handle.workspace_id, BackendResourceError::Unauthorized {
None, message: "Workspace resource endpoint does not match its request client"
BACKEND_RESOURCE_FETCH_PERMISSION, .to_string(),
"POST", }
endpoint.path(), })?;
&body, if !endpoint_suffix.starts_with('/') {
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), return Err(BackendResourceError::Unauthorized {
30, message: "Workspace resource endpoint does not match its request client"
) .to_string(),
.map_err(|error| BackendResourceError::Unauthorized { });
}
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
if let Some(token) = self.bearer_token.as_deref() {
let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|error| BackendResourceError::Transport {
message: error.to_string(), message: error.to_string(),
})?; })?;
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof); headers.insert(reqwest::header::AUTHORIZATION, value);
} }
let builder = if let Some(token) = self.bearer_token.as_deref() { let response = client
builder.bearer_auth(token) .execute(RuntimeWorkspaceRequest {
} else { method: reqwest::Method::POST,
builder path_and_query: endpoint_suffix.to_string(),
}; body,
let response = builder headers,
.send() permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(),
worker_id: None,
timeout: Some(self.request_timeout),
max_response_bytes: 8 * 1024 * 1024,
})
.await .await
.map_err(|err| BackendResourceError::Transport { .map_err(|error| {
message: err.to_string(), if error.is_timeout() {
BackendResourceError::Timeout
} else {
BackendResourceError::Transport {
message: error.to_string(),
}
}
})?; })?;
if response.status().is_success() { if response.status.is_success() {
response serde_json::from_slice::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
.json::<BackendResourceFetchResponse>() BackendResourceError::InvalidResponse {
.await
.map_err(|err| BackendResourceError::InvalidResponse {
message: err.to_string(), message: err.to_string(),
}) }
})
} else { } else {
let status = response.status(); let status = response.status;
match response.json::<BackendResourceError>().await { match serde_json::from_slice::<BackendResourceError>(&response.body) {
Ok(error) => Err(error), Ok(error) => Err(error),
Err(err) => Err(BackendResourceError::Transport { Err(err) => Err(BackendResourceError::Transport {
message: format!("backend resource fetch failed with HTTP {status}: {err}"), message: format!("backend resource fetch failed with HTTP {status}: {err}"),
@@ -266,23 +305,6 @@ impl BackendResourceClient for HttpBackendResourceClient {
} }
} }
#[derive(Default, Debug)]
pub struct ProfileSourceArchiveCache {
archives: Mutex<HashMap<String, ProfileSourceArchive>>,
}
impl ProfileSourceArchiveCache {
pub fn get(&self, digest: &str) -> Option<ProfileSourceArchive> {
self.archives.lock().ok()?.get(digest).cloned()
}
pub fn insert(&self, archive: ProfileSourceArchive) {
if let Ok(mut archives) = self.archives.lock() {
archives.insert(archive.reference.digest.clone(), archive);
}
}
}
pub fn build_profile_source_archive_fetch_request( pub fn build_profile_source_archive_fetch_request(
handle: BackendResourceHandle, handle: BackendResourceHandle,
runtime_id: &str, runtime_id: &str,
@@ -364,6 +386,7 @@ pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), Str
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::RuntimeIdentityMaterial;
use crate::profile_archive::ProfileSourceGraphSummary; use crate::profile_archive::ProfileSourceGraphSummary;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -401,6 +424,61 @@ mod tests {
} }
} }
#[cfg(feature = "http-server")]
#[tokio::test]
async fn http_backend_resource_fetch_has_a_bounded_timeout() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
futures::future::pending::<()>().await;
drop(stream);
});
let base_url = format!("http://{address}");
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let handle = handle_for(b"archive-bytes");
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
.with_request_timeout(std::time::Duration::from_millis(25))
.with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
"workspace-test",
base_url.clone(),
"runtime-test",
)
.with_runtime_request_source(&identity, base_url),
);
let error = client
.fetch_resource(BackendResourceFetchRequest {
audit_correlation_id: handle.audit_correlation_id.clone(),
handle,
runtime_id: "runtime-test".to_string(),
worker_id: None,
})
.await
.unwrap_err();
server.abort();
assert_eq!(error, BackendResourceError::Timeout);
}
#[test]
fn repository_ssh_access_secret_debug_redacts_all_secret_values() {
let secret = RepositorySshAccessSecret {
credential_candidates: vec![RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 2,
private_key: "PRIVATE KEY secret bytes".to_string(),
}],
known_hosts_entry: "host key secret bytes".to_string(),
};
let debug = format!("{secret:?}");
assert!(debug.contains("credential-1"));
assert!(!debug.contains("secret bytes"));
assert_eq!(debug.matches("[REDACTED]").count(), 2);
}
#[test] #[test]
fn response_verification_detects_digest_mismatch() { fn response_verification_detects_digest_mismatch() {
let bytes = b"archive-bytes"; let bytes = b"archive-bytes";
+31 -93
View File
@@ -39,7 +39,6 @@ pub struct WorkerRetentionInventory {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub run_generation: u64,
pub session_id: Option<String>, pub session_id: Option<String>,
pub segment_ids: Vec<String>, pub segment_ids: Vec<String>,
pub session_bytes: u64, pub session_bytes: u64,
@@ -118,7 +117,6 @@ pub struct WorkerRetentionExecutionRequest {
pub source_runtime_id: String, pub source_runtime_id: String,
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub expected_worker_revision: String, pub expected_worker_revision: String,
pub expected_run_generation: u64,
pub source_created_at: String, pub source_created_at: String,
pub removed_at: String, pub removed_at: String,
pub effective_profile: Option<String>, pub effective_profile: Option<String>,
@@ -171,7 +169,6 @@ pub(crate) trait WorkerRetentionProvider: Send + Sync {
workspace_id: &str, workspace_id: &str,
runtime_id: &str, runtime_id: &str,
worker_id: WorkerId, worker_id: WorkerId,
run_generation: u64,
) -> Result<WorkerRetentionInventory, RuntimeError>; ) -> Result<WorkerRetentionInventory, RuntimeError>;
fn execute( fn execute(
@@ -283,7 +280,7 @@ impl FsWorkerRetentionProvider {
continue; continue;
}; };
let worker_dir = self.worker_dir(worker_id); let worker_dir = self.worker_dir(worker_id);
let snapshot: WorkerGenerationSnapshot = match read_json( let snapshot: WorkerAggregateSnapshot = match read_json(
&worker_dir.join("worker.json"), &worker_dir.join("worker.json"),
"scan Worker retention inventory", "scan Worker retention inventory",
) { ) {
@@ -303,12 +300,7 @@ impl FsWorkerRetentionProvider {
)); ));
continue; continue;
} }
match self.inventory( match self.inventory(workspace_id, runtime_id, worker_id) {
workspace_id,
runtime_id,
worker_id,
snapshot.run_generation(),
) {
Ok(item) => workers.push(item), Ok(item) => workers.push(item),
Err(_) => diagnostics.push(runtime_aggregate_diagnostic( Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
&bounded_id, &bounded_id,
@@ -380,26 +372,18 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
workspace_id: &str, workspace_id: &str,
runtime_id: &str, runtime_id: &str,
worker_id: WorkerId, worker_id: WorkerId,
run_generation: u64,
) -> Result<WorkerRetentionInventory, RuntimeError> { ) -> Result<WorkerRetentionInventory, RuntimeError> {
let worker_dir = self.worker_dir(worker_id); let worker_dir = self.worker_dir(worker_id);
if !worker_dir.is_dir() { if !worker_dir.is_dir() {
return Err(RuntimeError::WorkerNotFound { worker_id }); return Err(RuntimeError::WorkerNotFound { worker_id });
} }
let worker: WorkerGenerationSnapshot = read_json( let worker: WorkerAggregateSnapshot = read_json(
&worker_dir.join("worker.json"), &worker_dir.join("worker.json"),
"inventory Worker retention", "inventory Worker retention",
)?; )?;
if worker.workspace_id.as_deref() != Some(workspace_id) { if worker.workspace_id.as_deref() != Some(workspace_id) {
return Err(RuntimeError::WorkerNotFound { worker_id }); return Err(RuntimeError::WorkerNotFound { worker_id });
} }
let current_run_generation = worker.run_generation();
if current_run_generation != run_generation {
return Err(RuntimeError::InvalidRequest(format!(
"Worker retention inventory expected generation {run_generation}, current generation is {}",
current_run_generation
)));
}
let session_dir = worker_dir.join("session"); let session_dir = worker_dir.join("session");
let (session_id, segment_ids, session_bytes) = if session_dir.is_dir() { let (session_id, segment_ids, session_bytes) = if session_dir.is_dir() {
let manifest: CanonicalSessionManifest = read_json( let manifest: CanonicalSessionManifest = read_json(
@@ -437,7 +421,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id, worker_id,
run_generation,
session_id, session_id,
segment_ids, segment_ids,
session_bytes, session_bytes,
@@ -497,21 +480,13 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
worker_id: request.worker_id, worker_id: request.worker_id,
}); });
} }
let snapshot: WorkerGenerationSnapshot = let snapshot: WorkerAggregateSnapshot =
read_json(&worker_dir.join("worker.json"), "execute Worker retention")?; read_json(&worker_dir.join("worker.json"), "execute Worker retention")?;
if snapshot.workspace_id.as_deref() != Some(request.workspace_id.as_str()) { if snapshot.workspace_id.as_deref() != Some(request.workspace_id.as_str()) {
return Err(RuntimeError::WorkerNotFound { return Err(RuntimeError::WorkerNotFound {
worker_id: request.worker_id, worker_id: request.worker_id,
}); });
} }
let run_generation = snapshot.run_generation();
if run_generation != request.expected_run_generation {
return Err(RuntimeError::InvalidRequest(format!(
"Worker retention plan expected generation {}, current generation is {}",
request.expected_run_generation, run_generation
)));
}
let archive = match request.session_disposition { let archive = match request.session_disposition {
SessionDisposition::Archive => { SessionDisposition::Archive => {
Some(commit_session_archive(self, request, &worker_dir)?) Some(commit_session_archive(self, request, &worker_dir)?)
@@ -572,30 +547,9 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct WorkerGenerationSnapshot { struct WorkerAggregateSnapshot {
#[serde(default)] #[serde(default)]
workspace_id: Option<String>, workspace_id: Option<String>,
execution: WorkerGenerationExecution,
}
#[derive(Deserialize)]
struct WorkerGenerationExecution {
binding: Option<WorkerGenerationBinding>,
}
#[derive(Deserialize)]
struct WorkerGenerationBinding {
run_generation: u64,
}
impl WorkerGenerationSnapshot {
fn run_generation(&self) -> u64 {
self.execution
.binding
.as_ref()
.map(|binding| binding.run_generation)
.unwrap_or(0)
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -1286,13 +1240,12 @@ mod tests {
fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap(); fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
} }
fn source(root: &Path, worker_id: WorkerId, generation: u64) { fn source(root: &Path, worker_id: WorkerId) {
let worker = root.join("workers").join(worker_id.to_string()); let worker = root.join("workers").join(worker_id.to_string());
write_json( write_json(
&worker.join("worker.json"), &worker.join("worker.json"),
&serde_json::json!({ &serde_json::json!({
"workspace_id": "workspace-a", "workspace_id": "workspace-a"
"execution": {"binding": {"run_generation": generation}}
}), }),
); );
write_json( write_json(
@@ -1301,22 +1254,17 @@ mod tests {
); );
fs::create_dir_all(worker.join("session/segments")).unwrap(); fs::create_dir_all(worker.join("session/segments")).unwrap();
fs::write(worker.join("session/segments/segment-a.jsonl"), b"one\n").unwrap(); fs::write(worker.join("session/segments/segment-a.jsonl"), b"one\n").unwrap();
fs::create_dir_all(worker.join(format!("runs/{generation}"))).unwrap(); fs::create_dir_all(worker.join("runs/attempt-a")).unwrap();
fs::write( fs::write(
worker.join(format!("runs/{generation}/worker.out.log")), worker.join("runs/attempt-a/worker.out.log"),
b"diagnostic\n", b"diagnostic\n",
) )
.unwrap(); .unwrap();
fs::write( fs::write(worker.join("runs/attempt-a/worker.sock"), b"not retained").unwrap();
worker.join(format!("runs/{generation}/worker.sock")),
b"not retained",
)
.unwrap();
} }
fn request( fn request(
worker_id: WorkerId, worker_id: WorkerId,
generation: u64,
disposition: SessionDisposition, disposition: SessionDisposition,
) -> WorkerRetentionExecutionRequest { ) -> WorkerRetentionExecutionRequest {
WorkerRetentionExecutionRequest { WorkerRetentionExecutionRequest {
@@ -1328,7 +1276,6 @@ mod tests {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
source_runtime_id: "runtime-a".to_string(), source_runtime_id: "runtime-a".to_string(),
worker_id, worker_id,
expected_run_generation: generation,
source_created_at: "2026-01-01T00:00:00Z".to_string(), source_created_at: "2026-01-01T00:00:00Z".to_string(),
removed_at: "2026-01-02T00:00:00Z".to_string(), removed_at: "2026-01-02T00:00:00Z".to_string(),
effective_profile: Some("builtin:coder".to_string()), effective_profile: Some("builtin:coder".to_string()),
@@ -1344,9 +1291,9 @@ mod tests {
fn archive_is_verified_before_source_removal_and_retry_converges() { fn archive_is_verified_before_source_removal_and_retry_converges() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(7); let worker_id = WorkerId::from_legacy_u64(7);
source(temp.path(), worker_id, 4); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker_id, 4, SessionDisposition::Archive); let request = request(worker_id, SessionDisposition::Archive);
let first = provider.execute(&request).unwrap(); let first = provider.execute(&request).unwrap();
assert!(first.source_removed); assert!(first.source_removed);
@@ -1375,7 +1322,7 @@ mod tests {
fn archive_failure_keeps_live_source_for_retry() { fn archive_failure_keeps_live_source_for_retry() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(8); let worker_id = WorkerId::from_legacy_u64(8);
source(temp.path(), worker_id, 2); source(temp.path(), worker_id);
let collision = temp.path().join("archives/workers/archive-a"); let collision = temp.path().join("archives/workers/archive-a");
fs::create_dir_all(&collision).unwrap(); fs::create_dir_all(&collision).unwrap();
fs::write(collision.join("manifest.json"), b"not-json").unwrap(); fs::write(collision.join("manifest.json"), b"not-json").unwrap();
@@ -1383,7 +1330,7 @@ mod tests {
assert!( assert!(
provider provider
.execute(&request(worker_id, 2, SessionDisposition::Archive)) .execute(&request(worker_id, SessionDisposition::Archive))
.is_err() .is_err()
); );
assert!( assert!(
@@ -1403,13 +1350,13 @@ mod tests {
fn target_inventory_and_execute_reject_cross_workspace_aggregate() { fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(16); let worker_id = WorkerId::from_legacy_u64(16);
source(temp.path(), worker_id, 3); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
assert!(matches!( assert!(matches!(
provider.inventory("other-workspace", "runtime-a", worker_id, 3), provider.inventory("other-workspace", "runtime-a", worker_id),
Err(RuntimeError::WorkerNotFound { .. }) Err(RuntimeError::WorkerNotFound { .. })
)); ));
let mut request = request(worker_id, 3, SessionDisposition::Purge); let mut request = request(worker_id, SessionDisposition::Purge);
request.workspace_id = "other-workspace".to_string(); request.workspace_id = "other-workspace".to_string();
assert!(matches!( assert!(matches!(
provider.execute(&request), provider.execute(&request),
@@ -1434,20 +1381,12 @@ mod tests {
} }
#[test] #[test]
fn purge_removes_aggregate_and_rejects_stale_generation() { fn purge_removes_worker_aggregate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let worker_id = WorkerId::from_legacy_u64(9); let worker_id = WorkerId::from_legacy_u64(9);
source(temp.path(), worker_id, 5); source(temp.path(), worker_id);
let stale = request(worker_id, 4, SessionDisposition::Purge); let mut current = request(worker_id, SessionDisposition::Purge);
assert!(provider.execute(&stale).is_err());
assert!(
temp.path()
.join(format!("workers/{worker_id}/session"))
.is_dir()
);
let mut current = request(worker_id, 5, SessionDisposition::Purge);
current.operation_id = "operation-current".to_string(); current.operation_id = "operation-current".to_string();
current.input_fingerprint = "fingerprint-current".to_string(); current.input_fingerprint = "fingerprint-current".to_string();
let result = provider.execute(&current).unwrap(); let result = provider.execute(&current).unwrap();
@@ -1464,9 +1403,9 @@ mod tests {
fn pending_receipt_recovers_delete_to_receipt_crash_window() { fn pending_receipt_recovers_delete_to_receipt_crash_window() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(11); let worker_id = WorkerId::from_legacy_u64(11);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker_id, 1, SessionDisposition::Archive); let request = request(worker_id, SessionDisposition::Archive);
let completed = provider.execute(&request).unwrap(); let completed = provider.execute(&request).unwrap();
let receipt_path = temp.path().join("retention/operations/operation-a.json"); let receipt_path = temp.path().join("retention/operations/operation-a.json");
let mut receipt: RetentionOperationReceipt = let mut receipt: RetentionOperationReceipt =
@@ -1482,9 +1421,9 @@ mod tests {
#[test] #[test]
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() { fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
source(temp.path(), WorkerId::from_legacy_u64(13), 2); source(temp.path(), WorkerId::from_legacy_u64(13));
let other_worker = WorkerId::from_legacy_u64(14); let other_worker = WorkerId::from_legacy_u64(14);
source(temp.path(), other_worker, 1); source(temp.path(), other_worker);
write_json( write_json(
&temp &temp
.path() .path()
@@ -1492,8 +1431,7 @@ mod tests {
.join(other_worker.to_string()) .join(other_worker.to_string())
.join("worker.json"), .join("worker.json"),
&serde_json::json!({ &serde_json::json!({
"workspace_id": "other-workspace", "workspace_id": "other-workspace"
"execution": {"binding": {"run_generation": 1}}
}), }),
); );
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap(); fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
@@ -1532,9 +1470,9 @@ mod tests {
fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() { fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(12); let worker_id = WorkerId::from_legacy_u64(12);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let mut request = request(worker_id, 1, SessionDisposition::Archive); let mut request = request(worker_id, SessionDisposition::Archive);
request.diagnostics_disposition = DiagnosticsDisposition::Retain; request.diagnostics_disposition = DiagnosticsDisposition::Retain;
provider.execute(&request).unwrap(); provider.execute(&request).unwrap();
@@ -1543,10 +1481,10 @@ mod tests {
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap(); serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
receipt.result.source_removed = false; receipt.result.source_removed = false;
fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap(); fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap();
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
fs::write( fs::write(
temp.path() temp.path()
.join("archives/diagnostics/operation-a/runs/1/worker.out.log"), .join("archives/diagnostics/operation-a/runs/attempt-a/worker.out.log"),
b"corrupt\n", b"corrupt\n",
) )
.unwrap(); .unwrap();
@@ -1564,9 +1502,9 @@ mod tests {
fn concurrent_retry_produces_one_archive() { fn concurrent_retry_produces_one_archive() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::from_legacy_u64(10); let worker_id = WorkerId::from_legacy_u64(10);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id);
let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path())); let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path()));
let request = Arc::new(request(worker_id, 1, SessionDisposition::Archive)); let request = Arc::new(request(worker_id, SessionDisposition::Archive));
let barrier = Arc::new(Barrier::new(3)); let barrier = Arc::new(Barrier::new(3));
let handles = (0..2) let handles = (0..2)
.map(|_| { .map(|_| {
File diff suppressed because it is too large Load Diff
@@ -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::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use worker::{ use worker::{
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
}; };
use crate::auth::{ use crate::auth::{
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial, RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION, WORKER_REMOVE_PERMISSION, WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION,
WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind, WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
}; };
use crate::runtime::RuntimeWorkspaceScope; use crate::runtime::RuntimeWorkspaceScope;
use crate::worker_backend::WorkspacePromptProjectionCache; use crate::worker_backend::WorkspacePromptProjectionCache;
@@ -133,9 +133,7 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
#[derive(Clone)] #[derive(Clone)]
enum RuntimeWorkerMutationTransport { enum RuntimeWorkerMutationTransport {
Remote { Remote {
base_url: String, request_client: RuntimeWorkspaceRequestClient,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
}, },
Embedded { Embedded {
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>, dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
@@ -155,17 +153,13 @@ impl RuntimeWorkerMutationForwarder {
identity: &RuntimeIdentityMaterial, identity: &RuntimeIdentityMaterial,
scope: RuntimeWorkspaceScope, scope: RuntimeWorkspaceScope,
source_worker_id: impl Into<String>, source_worker_id: impl Into<String>,
base_url: impl Into<String>, request_client: RuntimeWorkspaceRequestClient,
) -> Self { ) -> Self {
Self { Self {
authority: RuntimeWorkerMutationSourceAuthority::remote(identity), authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
scope: scope.clone(), scope: scope.clone(),
source_worker_id: source_worker_id.into(), source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Remote { transport: RuntimeWorkerMutationTransport::Remote { request_client },
base_url: base_url.into().trim_end_matches('/').to_string(),
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
request_source_audience: scope.server_id,
},
} }
} }
@@ -201,18 +195,11 @@ impl RuntimeWorkerMutationForwarder {
)?; )?;
match (&self.transport, proof) { match (&self.transport, proof) {
( (
RuntimeWorkerMutationTransport::Remote { RuntimeWorkerMutationTransport::Remote { request_client },
base_url,
request_source_signer,
request_source_audience,
},
RuntimeOwnedWorkerMutationProof::Remote(token), RuntimeOwnedWorkerMutationProof::Remote(token),
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest { ) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
base_url: base_url.clone(), request_client: request_client.clone(),
workspace_id: self.scope.workspace_id.clone(),
source_worker_id: self.source_worker_id.clone(), source_worker_id: self.source_worker_id.clone(),
request_source_signer: request_source_signer.clone(),
request_source_audience: request_source_audience.clone(),
token, token,
target_runtime_id: target_runtime_id.to_string(), target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(), target_worker_id: target_worker_id.to_string(),
@@ -233,11 +220,8 @@ impl RuntimeWorkerMutationForwarder {
} }
struct RemoteWorkerRemoveHttpRequest { struct RemoteWorkerRemoveHttpRequest {
base_url: String, request_client: RuntimeWorkspaceRequestClient,
workspace_id: String,
source_worker_id: String, source_worker_id: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
token: String, token: String,
target_runtime_id: String, target_runtime_id: String,
target_worker_id: String, target_worker_id: String,
@@ -270,54 +254,54 @@ fn execute_remote_worker_remove_http(
fn execute_remote_worker_remove_http_blocking( fn execute_remote_worker_remove_http_blocking(
request: RemoteWorkerRemoveHttpRequest, request: RemoteWorkerRemoveHttpRequest,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> { ) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let path = format!("/api/w/{}/workers/remove", request.workspace_id); let path = format!(
let url = format!("{}{}", request.base_url, path); "/api/w/{}/workers/remove",
let body = serde_json::to_string(&serde_json::json!({ request.request_client.workspace_id()
);
let body = serde_json::to_vec(&serde_json::json!({
"target_runtime_id": request.target_runtime_id, "target_runtime_id": request.target_runtime_id,
"target_worker_id": request.target_worker_id, "target_worker_id": request.target_worker_id,
"reason": request.reason, "reason": request.reason,
})) }))
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let request_source_proof = request.request_source_signer.issue( let mut headers = reqwest::header::HeaderMap::new();
&request.request_source_audience, headers.insert(
&request.workspace_id, crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
Some(&request.source_worker_id), reqwest::header::HeaderValue::from_str(&request.token)
WORKSPACE_REQUEST_PERMISSION, .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
"POST", );
&path, headers.insert(
body.as_bytes(), reqwest::header::CONTENT_TYPE,
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX), reqwest::header::HeaderValue::from_static("application/json"),
30, );
)?; let response = request
let client = reqwest::blocking::Client::new(); .request_client
let response = client .execute_blocking(RuntimeWorkspaceRequest {
.post(url) method: reqwest::Method::POST,
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof) path_and_query: path,
.header( body,
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER, headers,
request.token, permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
) worker_id: Some(request.source_worker_id),
.header(reqwest::header::CONTENT_TYPE, "application/json") timeout: Some(Duration::from_secs(5)),
.body(body) max_response_bytes: 8 * 1024 * 1024,
.send() })
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let status = response.status().as_u16(); let body = String::from_utf8(response.body)
let body = response
.text()
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
Ok(WorkspaceResponse { status, body }) Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
} }
#[derive(Clone)] #[derive(Clone)]
pub struct RuntimeOwnedWorkspaceClient { pub struct RuntimeOwnedWorkspaceClient {
workspace_id: String, workspace_id: String,
base_url: String, request_client: RuntimeWorkspaceRequestClient,
runtime_id: String,
worker_id: String, worker_id: String,
request_timeout: Option<Duration>, request_timeout: Option<Duration>,
worker_remove: Option<RuntimeWorkerMutationForwarder>, worker_remove: Option<RuntimeWorkerMutationForwarder>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>, prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
} }
@@ -328,15 +312,32 @@ impl RuntimeOwnedWorkspaceClient {
runtime_id: impl Into<String>, runtime_id: impl Into<String>,
worker_id: impl Into<String>, worker_id: impl Into<String>,
) -> Self { ) -> Self {
let workspace_id = workspace_id.into();
Self { Self {
workspace_id: workspace_id.into(), request_client: RuntimeWorkspaceRequestClient::new(
base_url: base_url.into().trim_end_matches('/').to_string(), workspace_id.clone(),
runtime_id: runtime_id.into(), base_url,
runtime_id,
),
workspace_id,
worker_id: worker_id.into(),
request_timeout: None,
worker_remove: None,
prompt_projection_cache: None,
}
}
pub(crate) fn from_request_client(
request_client: RuntimeWorkspaceRequestClient,
worker_id: impl Into<String>,
) -> Self {
let workspace_id = request_client.workspace_id().to_string();
Self {
workspace_id,
request_client,
worker_id: worker_id.into(), worker_id: worker_id.into(),
request_timeout: None, request_timeout: None,
worker_remove: None, worker_remove: None,
request_source_signer: None,
request_source_audience: None,
prompt_projection_cache: None, prompt_projection_cache: None,
} }
} }
@@ -351,8 +352,9 @@ impl RuntimeOwnedWorkspaceClient {
identity: &RuntimeIdentityMaterial, identity: &RuntimeIdentityMaterial,
audience: impl Into<String>, audience: impl Into<String>,
) -> Self { ) -> Self {
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity)); self.request_client = self
self.request_source_audience = Some(audience.into()); .request_client
.with_runtime_request_source(identity, audience);
self self
} }
@@ -375,44 +377,43 @@ impl RuntimeOwnedWorkspaceClient {
request: WorkspaceRequest, request: WorkspaceRequest,
permission: &'static str, permission: &'static str,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone(); let method = match request.method {
let workspace_id = self.workspace_id.clone(); WorkspaceRequestMethod::Get => reqwest::Method::GET,
let runtime_id = self.runtime_id.clone(); WorkspaceRequestMethod::Post => reqwest::Method::POST,
let worker_id = self.worker_id.clone(); WorkspaceRequestMethod::Put => reqwest::Method::PUT,
let request_source_signer = self.request_source_signer.clone(); WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
let request_source_audience = self.request_source_audience.clone(); WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
let request_timeout = self.request_timeout; };
if tokio::runtime::Handle::try_current().is_ok() { let body = request.body.unwrap_or_default().into_bytes();
std::thread::spawn(move || { let mut headers = reqwest::header::HeaderMap::new();
execute_runtime_owned_workspace_http( if !body.is_empty() {
&base_url, headers.insert(
&workspace_id, reqwest::header::CONTENT_TYPE,
&runtime_id, reqwest::header::HeaderValue::from_static("application/json"),
&worker_id, );
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout,
permission,
request,
)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.workspace_id,
&self.runtime_id,
&self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout,
permission,
request,
)
} }
let request_label = format!("{method} {}", request.path);
let response = self
.request_client
.execute_blocking(RuntimeWorkspaceRequest {
method,
path_and_query: request.path,
body,
headers,
permission: permission.to_string(),
worker_id: Some(self.worker_id.clone()),
timeout: self.request_timeout,
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| {
WorkspaceClientError::Request(format!("{request_label} failed: {error}"))
})?;
let body = String::from_utf8(response.body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
Ok(WorkspaceResponse {
status: response.status.as_u16(),
body,
})
} }
} }
@@ -420,8 +421,8 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter formatter
.debug_struct("RuntimeOwnedWorkspaceClient") .debug_struct("RuntimeOwnedWorkspaceClient")
.field("workspace_id", &self.workspace_id) .field("workspace_id", &self.request_client.workspace_id())
.field("base_url", &self.base_url) .field("base_url", &self.request_client.base_url())
.field("source", &"Runtime-owned") .field("source", &"Runtime-owned")
.field( .field(
"worker_remove", "worker_remove",
@@ -433,7 +434,7 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
impl WorkspaceClient for RuntimeOwnedWorkspaceClient { impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
fn workspace_id(&self) -> Option<&str> { fn workspace_id(&self) -> Option<&str> {
Some(&self.workspace_id) Some(self.request_client.workspace_id())
} }
fn kind(&self) -> &str { fn kind(&self) -> &str {
@@ -588,117 +589,6 @@ fn percent_encode_query(value: &str) -> String {
encoded encoded
} }
fn execute_runtime_owned_workspace_http(
base_url: &str,
workspace_id: &str,
runtime_id: &str,
worker_id: &str,
request_source_signer: Option<&RuntimeRequestSourceSigner>,
request_source_audience: Option<&str>,
request_timeout: Option<Duration>,
permission: &'static str,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !request.path.starts_with('/') || request.path.starts_with("//") {
return Err(WorkspaceClientError::InvalidPath(request.path));
}
let url = format!("{base_url}{}", request.path);
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
WorkspaceRequestMethod::Post => reqwest::Method::POST,
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
};
let client = reqwest::blocking::Client::builder()
.timeout(request_timeout)
.build()
.map_err(|error| {
WorkspaceClientError::Unavailable(format!(
"failed to build Workspace API HTTP client: {}",
reqwest_error_chain(&error)
))
})?;
let request_label = format!("{method} {}", request.path);
let body = request.body.unwrap_or_default();
let mut request_builder = client
.request(method.clone(), url)
.header("x-yoi-runtime-id", runtime_id)
.header("x-yoi-worker-id", worker_id);
if let Some(signer) = request_source_signer {
let audience = request_source_audience.ok_or_else(|| {
WorkspaceClientError::Request(
"runtime request proof audience is unavailable".to_owned(),
)
})?;
let proof = signer
.issue(
audience,
workspace_id,
Some(worker_id),
permission,
method.as_str(),
&request.path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if !body.is_empty() {
request_builder = request_builder
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
let response = request_builder
.send()
.map_err(|error| workspace_http_error(&request_label, "waiting for response", error))?;
let status = response.status().as_u16();
let body = response
.text()
.map_err(|error| workspace_http_error(&request_label, "reading response body", error))?;
Ok(WorkspaceResponse { status, body })
}
fn workspace_http_error(
request_label: &str,
stage: &str,
error: reqwest::Error,
) -> WorkspaceClientError {
let details = reqwest_error_chain(&error);
if error.is_timeout() {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} timed out while {stage}: {details}"
))
} else if error.is_connect() {
WorkspaceClientError::Unavailable(format!(
"Workspace API {request_label} could not connect while {stage}: {details}"
))
} else {
WorkspaceClientError::Request(format!(
"Workspace API {request_label} transport failed while {stage}: {details}"
))
}
}
fn reqwest_error_chain(error: &reqwest::Error) -> String {
let mut details = error.to_string();
let mut source = std::error::Error::source(error);
for _ in 0..4 {
let Some(current) = source else {
break;
};
let current_text = current.to_string();
if !current_text.is_empty() && !details.ends_with(&current_text) {
details.push_str(": ");
details.push_str(&current_text);
}
source = std::error::Error::source(current);
}
details
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RuntimeWorkerMutationForwardError { pub enum RuntimeWorkerMutationForwardError {
#[error(transparent)] #[error(transparent)]
@@ -722,9 +612,9 @@ fn unix_now_seconds() -> u64 {
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::{ use crate::auth::{
WorkerMutationSourceExpectation, decode_runtime_request_source_claims, RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WorkerMutationSourceExpectation,
decode_worker_mutation_source_claims, request_body_digest, decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
verify_worker_mutation_source_proof, request_body_digest, verify_worker_mutation_source_proof,
}; };
#[test] #[test]
@@ -1132,7 +1022,12 @@ mod tests {
&identity, &identity,
scope, scope,
"worker-source", "worker-source",
format!("http://{address}"), RuntimeWorkspaceRequestClient::new(
"workspace-a",
format!("http://{address}"),
"runtime-a",
)
.with_runtime_request_source(&identity, "server-a"),
); );
let response = forwarder let response = forwarder
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker") .execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
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"));
}
}

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