Compare commits
121 Commits
981f0501a3
...
b6bb22f956
| Author | SHA1 | Date | |
|---|---|---|---|
| b6bb22f956 | |||
| 6823f0e72d | |||
| b877c92975 | |||
| 13f9e0fab8 | |||
| 7d3b364728 | |||
| 391f11fc23 | |||
| 5d4cb9b3fe | |||
| 0736de6451 | |||
| 142b60e1b3 | |||
| a98e25eda8 | |||
| 10762a3c8b | |||
| 74b07bd787 | |||
| 14e704b858 | |||
| 2f7b809401 | |||
| 50eec7885a | |||
| 54eaa44681 | |||
| 7223cab0b2 | |||
| 10267869ae | |||
| 1d7765d9ee | |||
| 7d393f8c98 | |||
| 8a3fca65f2 | |||
| 83c9d8844d | |||
| 3a0600be0f | |||
| 0e01e5c84b | |||
| 69607d02bc | |||
| 33ae73e7ee | |||
| db5c1d64d1 | |||
| 0f2d7263a8 | |||
| c29e913511 | |||
| 19a52742a3 | |||
| 5d07e9b9d5 | |||
| 44410a0fdd | |||
| b50b94612a | |||
| 89ba205cac | |||
| dbc223e9e0 | |||
| 2a6930a946 | |||
| a5417320eb | |||
| a38fcdf04d | |||
| 4b2e03d7cc | |||
| 603a717e40 | |||
| e6b5013224 | |||
| 3ee9469c1a | |||
| bbcc91db01 | |||
| 366af8c544 | |||
| 505bbd7cc9 | |||
| e4653f7a2d | |||
| f7191056b7 | |||
| db3a7165f7 | |||
| 5c228a7fae | |||
| e40445b07a | |||
| 3df1c2dcfa | |||
| 6fa3d9d21f | |||
| 358fb32fe7 | |||
| 632a3310ea | |||
| 9d6400cf2f | |||
| c44bdb286a | |||
| cab6a5300d | |||
| 254360f1ab | |||
| 156f8ad044 | |||
| 1262b6022a | |||
| 4970a58c5a | |||
| 1b4d336d80 | |||
| c961185635 | |||
| 361569a69e | |||
| 29ddc168a3 | |||
| 85da6a6c33 | |||
| ba44391acf | |||
| 5f762a6ce3 | |||
| dcab396e69 | |||
| 03652f8210 | |||
| 763709fdf8 | |||
| 26e4efc990 | |||
| 239f93b084 | |||
| d9b3eb0e8b | |||
| e6b52b045a | |||
| 8695089fba | |||
| 522c7043b1 | |||
| 210f41e020 | |||
| 1ed0501f8e | |||
| 234d62bbef | |||
| 5f669b90ef | |||
| db8cfa8142 | |||
| c949e1b4f2 | |||
| c2afaefb5f | |||
| 3e5546ea33 | |||
| c88283dbe5 | |||
| 1564d7e411 | |||
| 452d07273c | |||
| 86f652a72f | |||
| 651422e0c8 | |||
| fe66437589 | |||
| 04cb2f700d | |||
| 24156947ac | |||
| 47e03bd54b | |||
| a3cb59af43 | |||
| d149f7e936 | |||
| 611f1656a3 | |||
| 69fd11a233 | |||
| 9f6abb675c | |||
| c3afcc7491 | |||
| ef0799c278 | |||
| d216b10100 | |||
| fa233ba025 | |||
| 36454e831b | |||
| 9a2e57fb0e | |||
| dccfe539ef | |||
| ae227c7e4d | |||
| 13c6548b46 | |||
| 2dfad3500f | |||
| c5a9f8a0ab | |||
| 941b91261b | |||
| 0458af721f | |||
| 129079cad4 | |||
| 81abfa630a | |||
| ce2ccce98e | |||
| 7da14f1a8d | |||
| afc43780a7 | |||
| b06e1b3dc2 | |||
| 23ab100d20 | |||
| 1400ebd2f1 | |||
| 292ad2c202 |
|
|
@ -2,8 +2,8 @@
|
||||||
title: "Runtime working directory materialization and sandboxed agent environments"
|
title: "Runtime working directory materialization and sandboxed agent environments"
|
||||||
state: "active"
|
state: "active"
|
||||||
created_at: "2026-07-06T16:28:12Z"
|
created_at: "2026-07-06T16:28:12Z"
|
||||||
updated_at: "2026-07-07T12:40:00Z"
|
updated_at: "2026-07-10T16:50:00Z"
|
||||||
linked_tickets: ["00001KWPC13WQ", "00001KWMBAA6V"]
|
linked_tickets: ["00001KWPC13WQ", "00001KWMBAA6V", "00001KX6BPY7M", "00001KX6CRVBE"]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
@ -34,7 +34,8 @@ Yoi は Workspace / Repository / Runtime を分ける方針になっている。
|
||||||
|
|
||||||
- Runtime root: Runtime が自身の store、cache、Worker metadata、working directory allocation を管理する root。長期的には `~/.yoi/runtimes/<runtime-id>/` 配下など、Workspace backend store とは別に置く。
|
- Runtime root: Runtime が自身の store、cache、Worker metadata、working directory allocation を管理する root。長期的には `~/.yoi/runtimes/<runtime-id>/` 配下など、Workspace backend store とは別に置く。
|
||||||
- Repository cache: Runtime-local の共有 source/cache。Git なら bare mirror / object cache / packfile cache など。重く、長寿命で、複数 Worker allocation から共有される。
|
- Repository cache: Runtime-local の共有 source/cache。Git なら bare mirror / object cache / packfile cache など。重く、長寿命で、複数 Worker allocation から共有される。
|
||||||
- working directory: Worker ごとの作業環境。短寿命で、Worker が読み書きする root / mounts / scratch / overlay を含む。Browser-facing UI/API では `Workspace` と混同しないよう、この呼称に寄せる。`Volume` は storage backing の候補名であり、この作業領域そのものの呼称にはしない。
|
- working directory: Worker ごとの作業環境。短寿命で、Worker が読み書きする root / mounts / scratch / overlay を含む。Browser-facing UI では `workspace` と混同しないよう `workdir` と表示してよい。`Volume` は storage backing の候補名であり、この作業領域そのものの呼称にはしない。
|
||||||
|
- Worker record: 作業単位として保存する record。profile、Runtime、RepositoryPoint / workdir evidence、session/transcript refs、status、diagnostics、summary、pinned flag を束ねる。Session 単体ではなく Worker を保存・削除の単位にする。
|
||||||
- Materialization strategy: RepositoryPoint から working directory を作る実装戦略。Git detached worktree、sparse checkout、CoW snapshot、reflink copy、overlay、container filesystem など。
|
- Materialization strategy: RepositoryPoint から working directory を作る実装戦略。Git detached worktree、sparse checkout、CoW snapshot、reflink copy、overlay、container filesystem など。
|
||||||
- WorkingDirectoryAllocation: Runtime が払い出した作業環境の record。allocation id、worker id、workspace id、repository point、materializer kind、root、mounts、cleanup policy、status を持つ。
|
- WorkingDirectoryAllocation: Runtime が払い出した作業環境の record。allocation id、worker id、workspace id、repository point、materializer kind、root、mounts、cleanup policy、status を持つ。
|
||||||
- Sandbox policy: Worker が見られる filesystem、network、process、secret、tool authority の境界を表す policy。
|
- Sandbox policy: Worker が見られる filesystem、network、process、secret、tool authority の境界を表す policy。
|
||||||
|
|
@ -286,6 +287,21 @@ Rift の filtered CoW creation のように、重い artifacts を除外しな
|
||||||
- Runtime が複数 Workspace / Repository の Worker を同時に抱える場合の namespace、quota、cleanup、audit boundary を固める。
|
- Runtime が複数 Workspace / Repository の Worker を同時に抱える場合の namespace、quota、cleanup、audit boundary を固める。
|
||||||
- remote/self-hosted/hosted Runtime fleet で repository cache と working directory storage をどう扱うかを設計する。
|
- remote/self-hosted/hosted Runtime fleet で repository cache と working directory storage をどう扱うかを設計する。
|
||||||
|
|
||||||
|
### 7. Worker / Session / workdir retention and cleanup policy
|
||||||
|
|
||||||
|
- Worker を保存・削除単位にする。Session / transcript は Worker に内包または参照される履歴として扱い、Session 単体を長期保存 authority にしない。
|
||||||
|
- Worker lifecycle は `running -> stopped -> delete` を基本にする。`archived` を lifecycle state として導入しない。
|
||||||
|
- Worker には `pinned` flag を持たせる。Pinned Worker は manual delete / cleanup / future automatic prune から守られる。
|
||||||
|
- Worker delete は Worker record と内包する Session / transcript history の削除を意味する。Workdir files は自動削除しない。
|
||||||
|
- Session / transcript は削除可能にする。圧縮 archive storage や高度な summarized retention は必要になった時点で別の storage policy として設計する。
|
||||||
|
- Workdir 実ファイルは durable history ではなく再現可能 cache として扱う。RepositoryPoint / resolved commit から再現でき、dirty/uncommitted state が無いなら、running Worker に紐づかない workdir files は manual cleanup eligible とする。
|
||||||
|
- Dirty Workdir は活動中または要判断状態として扱う。削除は可能だが、changes ごと消す explicit confirmation を要求する。Dirty orphan は recovery Worker 起動または explicit discard の判断対象であり、通常の clean cleanup と区別する。
|
||||||
|
- Workdir record は materialization evidence として扱う。実ファイルが削除済みなら `removed`、外部要因で欠落しているなら stale/missing materialization として診断可能にする。
|
||||||
|
- Worker と Workdir の関係は Backend registry の link table を authority にする。Worker record は最後に活動した RepositoryPoint / resolved commit / workdir binding summary を保持し、Stopped Worker + Removed Workdir でも必要に応じて再 materialize できるようにする。
|
||||||
|
- Cleanup/delete は当面 manual-first にする。削除前に対象、理由、削除される bytes、blocking conditions(running Worker、pinned Worker、dirty state、unreachable commit など)を plan として提示する。
|
||||||
|
- 将来的には容量/期限ベースの automatic prune を設定可能にする予定。ただし automatic prune は user-configured policy と pinned protection を前提にし、初期の manual cleanup/delete とは別段階で扱う。
|
||||||
|
- UI 表示は `workdir` に寄せる。内部型/API の互換名 `working_directory` は移行中に残ってよいが、Browser-facing navigation では Runtime 管理配下の `Workdirs` として扱う。
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- v0 で完全な container sandbox を実装すること。
|
- v0 で完全な container sandbox を実装すること。
|
||||||
|
|
@ -307,6 +323,9 @@ Rift の filtered CoW creation のように、重い artifacts を除外しな
|
||||||
- Runtime process 起動時の `--workspace` は legacy bootstrap input として隔離され、Runtime identity や single workspace binding とみなされない。
|
- Runtime process 起動時の `--workspace` は legacy bootstrap input として隔離され、Runtime identity や single workspace binding とみなされない。
|
||||||
- Worker ごとの scope allocation conflict が、同一 source root を直接渡す設計ではなく materialized workspace allocation によって解消される。
|
- Worker ごとの scope allocation conflict が、同一 source root を直接渡す設計ではなく materialized workspace allocation によって解消される。
|
||||||
- Sandbox / mount / cache / secret boundary を後続実装で強化できる model になっている。
|
- Sandbox / mount / cache / secret boundary を後続実装で強化できる model になっている。
|
||||||
|
- Worker record が Session / transcript refs と pinned flag を束ね、`pinned` Worker を cleanup/delete/future automatic prune から守れる。
|
||||||
|
- Workdir 実ファイルは再現可能 cache として manual cleanup/delete でき、削除前に plan-first で linked Worker、session retention、commit reachability、dirty/missing 状態を確認できる。
|
||||||
|
- 将来的に容量/期限ベースの automatic prune を user-configured policy として追加できる設計余地がある。
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
default = "builtin:companion"
|
default = "builtin:companion"
|
||||||
|
|
||||||
|
[profile]
|
||||||
|
|
|
||||||
0
.yoi/tickets/00001KX1JNJ2Y/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX1JNJ2Y/artifacts/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"id":"orch-plan-20260709-075750-1","ticket_id":"00001KX1JNJ2Y","kind":"accepted_plan","accepted_plan":{"summary":"`00001KWZ5KERY` Decodal/ProfileSourceArchive と `00001KX0DSMPT` Workspace/Profile settings が closed/merged 済み。ProfileSourceTree virtual filesystem と Decodal editor follow-up を実装開始する。","branch":"work/00001KX1JNJ2Y-profile-source-tree","worktree":"/home/hare/Projects/yoi/.worktree/00001KX1JNJ2Y-profile-source-tree","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、ProfileSourceTree virtual FS model/resolver、ProfileSourceArchive tree-based builder、Runtime archive virtual-path loader validation、scoped file-tree settings API、Decodal CodeMirror editor UI、tests/Nix dependency update を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / path-redaction / import-resolution authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-09T07:57:50Z"}
|
||||||
13
.yoi/tickets/00001KX1JNJ2Y/artifacts/relations.json
Normal file
13
.yoi/tickets/00001KX1JNJ2Y/artifacts/relations.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"ticket_id": "00001KX1JNJ2Y",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"target": "00001KWZ5KERY",
|
||||||
|
"note": "ProfileSourceTree builds on the Decodal ProfileSourceArchive migration and refines import/source graph handling.",
|
||||||
|
"author": "yoi ticket",
|
||||||
|
"at": "2026-07-08T19:19:23Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
167
.yoi/tickets/00001KX1JNJ2Y/item.md
Normal file
167
.yoi/tickets/00001KX1JNJ2Y/item.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
---
|
||||||
|
title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-08T19:18:25Z'
|
||||||
|
updated_at: '2026-07-09T09:18:48Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'workspace-panel'
|
||||||
|
queued_at: '2026-07-09T07:56:07Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Decodal Profile は import を持つため、Profile source を単一文字列や RDB-like な正規化モデルだけで扱うと破綻する。Backend は Workspace/Profile source の正本を持つが、Browser / Runtime に host absolute path や raw filesystem authority を出してはいけない。
|
||||||
|
|
||||||
|
そのため、Backend-owned な仮想 filesystem abstraction として `ProfileSourceTree` を導入する。ユーザーや Browser editor は file tree / relative path として Decodal source を扱い、Backend はその tree revision から import closure を解決して `ProfileSourceArchive` を生成する。Runtime は `ProfileSourceArchive` の manifest / virtual path だけを見て Decodal import を解決し、Workspace filesystem は読まない。
|
||||||
|
|
||||||
|
既存 Ticket `00001KWZ5KERY` は Decodal ProfileSourceArchive migration を扱った。これは closed 済みなので、本 Ticket はその follow-up として virtual FS / source tree layer と Browser editor を追加する。
|
||||||
|
|
||||||
|
## 実装順序
|
||||||
|
|
||||||
|
- depends_on: `00001KWZ5KERY` — Decodal ProfileSourceArchive / archive verify / Runtime FSなし resolution の基礎。
|
||||||
|
- 本 Ticket: `ProfileSourceTree` virtual filesystem、import resolver / archive builder、Backend/Profile settings file-tree API、Decodal profile editor を実装する。
|
||||||
|
- 後続: formatter、advanced editor integrations、runtime-local artifact sync は別 Ticket とする。
|
||||||
|
|
||||||
|
## 実装目的
|
||||||
|
|
||||||
|
- Decodal import を Backend authority で決定的に解決できる。
|
||||||
|
- Browser editor は host path ではなく virtual relative path の file tree を編集する。
|
||||||
|
- Runtime は Workspace filesystem を読まず、archive manifest 内 virtual path だけで import を解決する。
|
||||||
|
- `ProfileSourceArchive` は `ProfileSourceTree` revision + selected root + import closure の snapshot として生成される。
|
||||||
|
- Workspace settings の Profiles 画面から Decodal source tree を閲覧・編集できる。
|
||||||
|
- Decodal editor は `decodal-codemirror` を使い、CodeMirror 6 の highlighting / indentation / folding を提供する。
|
||||||
|
- Backend validation が authority であり、Browser editor の diagnostics は補助として扱う。
|
||||||
|
|
||||||
|
## 実装内容
|
||||||
|
|
||||||
|
### 1. `ProfileSourceTree` model を追加する
|
||||||
|
|
||||||
|
Backend-owned Profile source の仮想 filesystem model を定義する。
|
||||||
|
|
||||||
|
含めるもの:
|
||||||
|
|
||||||
|
- `source_tree_id`。
|
||||||
|
- `revision` / generation。
|
||||||
|
- virtual root metadata。
|
||||||
|
- files: virtual relative path, content digest, size, content type, editable flag。
|
||||||
|
- source provenance: builtin / workspace / project / generated。
|
||||||
|
- diagnostics。
|
||||||
|
|
||||||
|
禁止するもの:
|
||||||
|
|
||||||
|
- host absolute path。
|
||||||
|
- symlink target path。
|
||||||
|
- Runtime endpoint / socket / session path。
|
||||||
|
- secret values。
|
||||||
|
- WorkingDirectory root path。
|
||||||
|
|
||||||
|
### 2. Virtual path resolver を実装する
|
||||||
|
|
||||||
|
Decodal import specifier を `current virtual path + specifier` から tree 内 virtual path へ解決する。
|
||||||
|
|
||||||
|
実装すること:
|
||||||
|
|
||||||
|
- relative import: `./foo.dcdl`, `../shared/base.dcdl`。
|
||||||
|
- explicit namespace import: builtin / workspace / project など、採用する namespace を typed enum として扱う。
|
||||||
|
- path normalization。
|
||||||
|
- root escape rejection。
|
||||||
|
- absolute path / URL / unsupported scheme rejection。
|
||||||
|
- extension/content type validation。
|
||||||
|
- import depth / file count / total bytes limit。
|
||||||
|
|
||||||
|
### 3. `ProfileSourceArchive` builder を `ProfileSourceTree` に接続する
|
||||||
|
|
||||||
|
Archive builder は selected root virtual path から import closure を解決し、closure だけを tar に入れる。
|
||||||
|
|
||||||
|
実装すること:
|
||||||
|
|
||||||
|
- selected profile selector -> root virtual path の解決。
|
||||||
|
- import closure traversal。
|
||||||
|
- manifest に virtual path / source key / digest / size / content type / import map を記録。
|
||||||
|
- archive digest は manifest + source contents の canonical identity にする。
|
||||||
|
- missing import / ambiguous namespace / duplicate source key / oversized closure を typed diagnostic にする。
|
||||||
|
|
||||||
|
### 4. Runtime `ArchiveSourceLoader` の virtual path validation を強化する
|
||||||
|
|
||||||
|
Runtime は archive manifest の virtual path だけを信頼境界として扱う。
|
||||||
|
|
||||||
|
実装すること:
|
||||||
|
|
||||||
|
- archive内 source path は manifest に存在するものだけ許可。
|
||||||
|
- Decodal `SourceLoader::load(current_key, specifier)` は archive manifest / import map / virtual path resolver を使う。
|
||||||
|
- archive tar path と virtual path の対応を検証する。
|
||||||
|
- absolute path / `..` / manifest外 import / unsupported namespace を拒否する。
|
||||||
|
|
||||||
|
### 5. Backend/Profile settings API を file-tree model に寄せる
|
||||||
|
|
||||||
|
Profile settings API は source list だけではなく、editor が使う file tree operation を提供する。
|
||||||
|
|
||||||
|
v0 で追加する operation:
|
||||||
|
|
||||||
|
- list source trees。
|
||||||
|
- list files in source tree。
|
||||||
|
- read file。
|
||||||
|
- write file。
|
||||||
|
- create file。
|
||||||
|
- delete file。
|
||||||
|
- validate tree / validate file。
|
||||||
|
- rename/move file は v0 で必要なら追加、不要なら非目標。
|
||||||
|
|
||||||
|
Browser-facing API は virtual path と safe display path だけを返し、host path を返さない。
|
||||||
|
|
||||||
|
### 6. Workspace settings に Decodal profile editor を追加する
|
||||||
|
|
||||||
|
Profiles settings 画面に file tree editor を追加する。
|
||||||
|
|
||||||
|
実装すること:
|
||||||
|
|
||||||
|
- `/w/<workspace-id>/settings/profiles` は Available profiles と Profile source tree list を表示する。
|
||||||
|
- `/w/<workspace-id>/settings/profiles/trees/<source-tree-id>` で source tree を開く。
|
||||||
|
- source tree page は file tree / selected file / diagnostics を表示する。
|
||||||
|
- selected file は `decodal-codemirror@0.1.2` の CodeMirror 6 extension で編集する。
|
||||||
|
- save は Backend file write API を使う。
|
||||||
|
- create/delete file は Backend API を使う。
|
||||||
|
- import diagnostics / validation diagnostics を表示する。
|
||||||
|
- Browser editor は Backend validation の補助であり、Backend diagnostics を authority として表示する。
|
||||||
|
- formatter は実装しない。
|
||||||
|
|
||||||
|
### 7. Editor dependency / packaging を追加する
|
||||||
|
|
||||||
|
Frontend に Decodal editor dependency を追加する。
|
||||||
|
|
||||||
|
実装すること:
|
||||||
|
|
||||||
|
- `decodal-codemirror@0.1.2` を frontend dependency に追加する。
|
||||||
|
- CodeMirror 6 の必要 dependency を追加する。
|
||||||
|
- `decodal()` extension を使う。
|
||||||
|
- `decodal-wasm` による browser-side evaluation は必要になるまで非目標とし、入れる場合も Backend validation を authority とする。
|
||||||
|
- Deno/SvelteKit check/test と Nix packaging に dependency 追加を反映する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `ProfileSourceTree` typed model が実装されている。
|
||||||
|
- Backend が Decodal source files を virtual relative path の tree として list/read/write/create/delete できる。
|
||||||
|
- Backend virtual path resolver が relative import と採用 namespace import を解決できる。
|
||||||
|
- absolute path、URL、root escape、symlink escape、unsupported scheme、oversized closure は typed diagnostic になる。
|
||||||
|
- `ProfileSourceArchive` builder が `ProfileSourceTree` revision から selected root の import closure tar を生成する。
|
||||||
|
- Archive manifest に virtual path / digest / import map が含まれる。
|
||||||
|
- Runtime `ArchiveSourceLoader` は archive manifest 内 virtual path だけで import を解決し、Workspace filesystem を読まない。
|
||||||
|
- Profiles settings 画面に source tree list と file tree editor がある。
|
||||||
|
- Decodal file editor は `decodal-codemirror@0.1.2` / CodeMirror 6 を使って highlighting / indentation / folding を提供する。
|
||||||
|
- Editor save/create/delete は Backend API を通り、host filesystem path を Browser が扱わない。
|
||||||
|
- Browser-facing API に host absolute path、Runtime endpoint、secret value、WorkingDirectory root が露出しない。
|
||||||
|
- Focused tests が virtual path normalization、import closure build、archive manifest validation、Runtime archive import resolution、Browser payload redaction、settings file tree API、editor route smoke behavior を確認する。
|
||||||
|
- `cargo test -p yoi-workspace-server` が通る。
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
|
||||||
|
- `cargo check -p yoi` が通る。
|
||||||
|
- `cd web/workspace && deno task check && deno task test` が通る。
|
||||||
|
- `yoi ticket doctor` が通る。
|
||||||
|
- `nix build .#yoi --no-link` が通る。
|
||||||
|
|
||||||
|
## 非目標
|
||||||
|
|
||||||
|
- Decodal formatter / formatter integration。
|
||||||
|
- Browser-side Decodal materialization as authority。
|
||||||
|
- Profile source を RDB-like form に正規化する編集 UI。
|
||||||
|
- Plugin package / MCP executable / sandbox artifact sync。
|
||||||
|
- Memory / Ticket / Objective / Workflow の API migration。
|
||||||
39
.yoi/tickets/00001KX1JNJ2Y/resolution.md
Normal file
39
.yoi/tickets/00001KX1JNJ2Y/resolution.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
完了。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend-owned `ProfileSourceTree` virtual filesystem model を追加した。
|
||||||
|
- virtual relative paths / safe source tree ids / file ids / revision / content digest / content type / typed provenance / diagnostics を扱う safe summaries を追加した。
|
||||||
|
- Profile source tree list/read/write/create/delete/validate scoped APIs を追加した。
|
||||||
|
- virtual import resolver を追加し、relative imports / scoped namespace imports を扱い、absolute path、URL/unsupported scheme、root escape、unsupported namespace、limit excess を fail closed するようにした。
|
||||||
|
- `ProfileSourceArchive` construction を selected root/selector + source tree revision + import closure snapshot に変更し、unrelated sources を archive に含めないようにした。
|
||||||
|
- Runtime `ArchiveSourceLoader` / archive verification を virtual path / import-map only に強化し、Workspace filesystem/backend fallback を導入しないまま維持した。
|
||||||
|
- Settings UI に Profile source tree list と Decodal source editor route を追加した。
|
||||||
|
- `decodal-codemirror@0.1.2` と CodeMirror 6 の `decodal()` extension を editor に接続した。
|
||||||
|
- UI create/save/delete operations を Backend virtual-path APIs に接続した。
|
||||||
|
- `.yoi/profiles` source root symlink と symlink/path escape を list/read/write/delete/archive construction paths で reject し、outside filesystem mutation 前に fail closed するようにした。
|
||||||
|
- Focused backend/runtime/web tests を追加した。
|
||||||
|
|
||||||
|
主な commit / merge:
|
||||||
|
- implementation: `81abfa63 feat: add profile source tree editor`
|
||||||
|
- review fix: `941b9126 fix: complete profile source tree review fixes`
|
||||||
|
- symlink-root fix: `13c6548b fix: reject profile source root symlinks`
|
||||||
|
- merge into orchestration: `9a2e57fb merge: profile source tree editor`
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 external review は request_changes。
|
||||||
|
- first follow-up review も request_changes(source-root symlink blocker)。
|
||||||
|
- second follow-up review は approve。merge-blocking issue なし。
|
||||||
|
|
||||||
|
Final validation in Orchestrator worktree:
|
||||||
|
- `git diff --check`: pass
|
||||||
|
- `cargo test -p yoi-workspace-server`: pass(94 lib tests + 2 main tests)
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests)
|
||||||
|
- `cargo check -p yoi`: pass
|
||||||
|
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||||
|
- `cd web/workspace && deno task test`: pass(19 tests)
|
||||||
|
- `yoi ticket doctor`: ok
|
||||||
|
- `nix build .#yoi --no-link`: pass
|
||||||
|
|
||||||
|
補足:
|
||||||
|
- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。
|
||||||
|
- Reviewer の non-blocking follow-up: `read_profile_tree_file` に対する root-symlink direct assertion を追加すると regression matrix がさらに明示的になるが、同じ guarded `checked_source_path` を通るため merge blocker ではない。
|
||||||
431
.yoi/tickets/00001KX1JNJ2Y/thread.md
Normal file
431
.yoi/tickets/00001KX1JNJ2Y/thread.md
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-08T19:18:25Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-09T07:18:33Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-09T07:18:33Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: workspace-panel at: 2026-07-09T07:56:07Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-09T07:57:36Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Declared dependency `00001KWZ5KERY` is closed, so Decodal ProfileSourceArchive / archive verify / Runtime FSなし resolution の基礎は available。
|
||||||
|
- Related follow-up `00001KX0DSMPT` Workspace/Profile settings API/pages is also closed, so settings/profile editing surface exists and this Ticket can refine it to ProfileSourceTree virtual filesystem + Decodal editor。
|
||||||
|
- Ticket body gives concrete model/API/UI/editor/dependency/validation requirements and clear non-goals。
|
||||||
|
- typed relation blocker is resolved; OrchestrationPlan record は 0 件。inprogress Ticket も 0 件。
|
||||||
|
- queued notification is human authorized routing/start。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX1JNJ2Y)`: outgoing `depends_on 00001KWZ5KERY`; target is closed。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX1JNJ2Y)`: 0 件。
|
||||||
|
- `TicketList`: queued はこの Ticket 1件、inprogress は 0 件。
|
||||||
|
- Orchestrator worktree git status: clean on `orchestration`。
|
||||||
|
- Relevant prior work: Decodal/ProfileSourceArchive closed, Workspace/Profile settings closed。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
|
||||||
|
Intent:
|
||||||
|
- Add Backend-owned `ProfileSourceTree` virtual filesystem for Decodal profile sources, connect ProfileSourceArchive import closure building to that tree, strengthen Runtime archive import validation, and add Workspace settings Profile source tree editor using `decodal-codemirror@0.1.2` / CodeMirror 6。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- Browser/editor works with virtual relative paths and safe source tree/file ids, never host absolute paths or raw filesystem authority。
|
||||||
|
- Backend validation is authority; Browser editor diagnostics are only assistance。
|
||||||
|
- Runtime reads only ProfileSourceArchive manifest / virtual paths, not Workspace filesystem。
|
||||||
|
- `ProfileSourceArchive` is a snapshot of source tree revision + selected root + import closure。
|
||||||
|
- Browser-facing API must not expose host absolute path, symlink target path, Runtime endpoint/socket/session path, secret values, WorkingDirectory root path, raw resource handles, archive digest/content, or backend-private paths。
|
||||||
|
- `decodal-codemirror@0.1.2` / CodeMirror 6 dependency is in scope; formatter and Browser-side Decodal materialization as authority are out of scope。
|
||||||
|
|
||||||
|
Requirements / acceptance criteria:
|
||||||
|
- Implement typed `ProfileSourceTree` model with tree id, revision/generation, virtual root metadata, files, provenance, diagnostics, and safe summaries。
|
||||||
|
- Implement virtual import resolver for relative imports and adopted namespace imports, with normalization and typed diagnostics for root escape, absolute path, URL/unsupported scheme, extension/content-type, depth/count/bytes limits。
|
||||||
|
- Build `ProfileSourceArchive` from selected root virtual path and source tree import closure, including virtual path / source key / digest / size / content type / import map in manifest。
|
||||||
|
- Runtime `ArchiveSourceLoader` validates manifest-contained virtual paths only and rejects manifest-outside import, absolute path, `..`, unsupported namespace。
|
||||||
|
- Add scoped Profile settings file-tree API: list trees, list files, read/write/create/delete file, validate tree/file; rename/move optional。
|
||||||
|
- Add Settings UI profile source tree list and file tree editor routes/pages using Decodal CodeMirror extension and Backend save/create/delete/validate APIs。
|
||||||
|
- Focused backend/runtime/web tests cover path normalization, import closure, archive manifest validation, Runtime import resolution, Browser redaction, file tree API, and editor route smoke。
|
||||||
|
|
||||||
|
Implementation latitude:
|
||||||
|
- Exact endpoint names, source tree storage format, namespace enum details, UI route/component split, and editor model can follow existing workspace-server/Svelte style。
|
||||||
|
- v0 may keep source tree storage minimal and project/workspace-backed, provided Browser contract remains virtual-path based and Backend validation remains authority。
|
||||||
|
|
||||||
|
Escalate if:
|
||||||
|
- Browser-facing raw host path authority, Runtime workspace filesystem discovery, Browser-side Decodal materialization as authority, formatter integration, plugin/MCP/sandbox artifact sync, or Profile source RDB-normalized editor becomes required。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo test -p yoi-workspace-server`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `git diff --check`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Current code map:
|
||||||
|
- Backend profile settings/source model: `crates/workspace-server/src/profile_settings.rs`, `server.rs`, `hosts.rs`。
|
||||||
|
- Decodal/Profile archive: `crates/worker-runtime/src/profile_archive.rs`, `config_bundle.rs`。
|
||||||
|
- Frontend settings/profile editor: `web/workspace/src/routes/w/[workspaceId]/settings/**`, `web/workspace/src/lib/workspace-settings/**`。
|
||||||
|
|
||||||
|
Critical risks / reviewer focus:
|
||||||
|
- virtual paths and tar paths must not allow absolute path / `..` / symlink/path escape。
|
||||||
|
- Browser must never receive host absolute path, secret, runtime endpoint, archive digest/content, or raw handle。
|
||||||
|
- Runtime SourceLoader must be archive-manifest/import-map only, no FS/backend fallback。
|
||||||
|
- source tree validation/write must be revision-aware and fail closed with typed diagnostics。
|
||||||
|
- new editor dependency and Nix/lock updates must be correct。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-09T07:57:59Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Queued acceptance recorded after Workspace Dashboard Queue notification authorized Orchestrator routing.
|
||||||
|
|
||||||
|
Checked context:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX1JNJ2Y)`: depends_on `00001KWZ5KERY`; target is closed。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX1JNJ2Y)`: prior record 0 件。今回 accepted_plan を記録済み。
|
||||||
|
- Orchestrator worktree git status: clean on `orchestration`。
|
||||||
|
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
|
||||||
|
|
||||||
|
Acceptance basis:
|
||||||
|
- concrete missing decision / information は残っていない。
|
||||||
|
- Ticket scope is ProfileSourceTree virtual filesystem + Decodal editor follow-up on top of closed Decodal/ProfileSourceArchive and Workspace/Profile settings work。
|
||||||
|
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-09T07:58:54Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation routing update:
|
||||||
|
|
||||||
|
- Worktree-workflow に従い implementation worktree を作成した。
|
||||||
|
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KX1JNJ2Y-profile-source-tree`
|
||||||
|
- Branch: `work/00001KX1JNJ2Y-profile-source-tree`
|
||||||
|
- Worktree には tracked `.yoi` project records が見えている。
|
||||||
|
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
|
||||||
|
- Sibling Coder Pod `yoi-coder-00001KX1JNJ2Y-source-tree` を起動した。
|
||||||
|
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
|
||||||
|
- 初回プロンプト肥大化を避けるため、Coder には短い task と Ticket record 読み取り指示を渡した。
|
||||||
|
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-09T08:28:11Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod completed implementation and committed `81abfa63 feat: add profile source tree editor` on branch `work/00001KX1JNJ2Y-profile-source-tree`.
|
||||||
|
- Worktree status was clean after commit。
|
||||||
|
- Changed files include:
|
||||||
|
- `crates/worker-runtime/src/profile_archive.rs`
|
||||||
|
- `crates/workspace-server/src/profile_settings.rs`
|
||||||
|
- `crates/workspace-server/src/server.rs`
|
||||||
|
- `web/workspace/deno.json`, `web/workspace/deno.lock`
|
||||||
|
- `web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte`
|
||||||
|
- `web/workspace/src/lib/workspace-settings/{profile-api.ts,profile-types.ts}`
|
||||||
|
- `web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte`
|
||||||
|
- child implementation also committed `.yoi/tickets/00001KX1JNJ2Y/{item.md,thread.md}` changes; Orchestrator will preserve authoritative Ticket records during merge if conflicts occur。
|
||||||
|
- Implementation summary:
|
||||||
|
- Added Runtime archive validation for virtual source metadata, duplicate/undeclared entries, scoped import maps, namespace/path safety, and relative virtual import resolution。
|
||||||
|
- Added Backend-owned project `ProfileSourceTree` support with virtual `profiles/...` paths, tree scanning, revision-aware file read/write/delete API, and tree-based archive/config-bundle generation。
|
||||||
|
- Added workspace server routes and Browser API/types for scoped source-tree access without host paths or backend-private/archive internals。
|
||||||
|
- Added Profiles settings Decodal CodeMirror editor using virtual paths and revision tokens。
|
||||||
|
- Added worker-runtime tests for scoped virtual import resolution and import-map mismatch rejection。
|
||||||
|
- Orchestrator reran validation in the implementation worktree:
|
||||||
|
- `git diff --check`: pass
|
||||||
|
- `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests)
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests)
|
||||||
|
- `cargo check -p yoi`: pass
|
||||||
|
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||||
|
- `cd web/workspace && deno task test`: pass(17 tests)
|
||||||
|
- `yoi ticket doctor`: ok
|
||||||
|
- `nix build .#yoi --no-link`: pass
|
||||||
|
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-09T08:32:35Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
External review result: request_changes
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
|
||||||
|
1. Decodal CodeMirror integration is missing。
|
||||||
|
- Ticket requires `decodal-codemirror@0.1.2` and the `decodal()` CodeMirror 6 extension。
|
||||||
|
- Implementation only adds `@codemirror/state` / `@codemirror/view` (`web/workspace/deno.json`) and `deno.lock` has no `decodal-codemirror` specifier。
|
||||||
|
- `DecodalSourceEditor.svelte` builds a plain CodeMirror editor with line numbers/selection/theme only, so there is no Decodal highlighting/indent/folding extension。
|
||||||
|
|
||||||
|
2. Settings UI does not implement the required tree route or create/delete editor operations。
|
||||||
|
- Ticket requires `/w/<workspace-id>/settings/profiles/trees/<source-tree-id>` plus save/create/delete via Backend APIs。
|
||||||
|
- Route tree only contains `web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte`; no `trees/[sourceTreeId]` route。
|
||||||
|
- Page imports/uses only `writeProfileTreeFile` and renders only Save; no create/delete UI is wired despite `deleteProfileTreeFile` existing in API wrapper。
|
||||||
|
|
||||||
|
3. Archive construction is not a selected-root import-closure snapshot。
|
||||||
|
- `build_workspace_profile_archive(workspace_root, selector)` reads whole tree and calls `build_profile_archive_from_tree_sources`; selector is used only for post-build validation。
|
||||||
|
- Builder adds entrypoints for every project registry entry, scans every source for imports, and passes entire `sources` map into `ProfileSourceArchive::build`。
|
||||||
|
- This violates requirement that archive is generated from `ProfileSourceTree revision + selected root + import closure`, with closure-only contents。
|
||||||
|
|
||||||
|
4. `ProfileSourceTree` typed model is incomplete against Ticket contract。
|
||||||
|
- Browser/backend structs expose tree id/root/revision/file count and file path/kind/revision/size, but no content digest, content type, or provenance field/typed provenance enum。
|
||||||
|
- These are explicit Ticket requirements for the typed model。
|
||||||
|
|
||||||
|
5. Symlink/path-escape write safety has a side-effect before containment validation。
|
||||||
|
- `write_profile_tree_file` calls `fs::create_dir_all(parent)` on unresolved workspace path before `checked_source_path` canonical root containment checks。
|
||||||
|
- With a symlinked intermediate directory under `.yoi/profiles`, this can create directories outside source root before later rejection。
|
||||||
|
- Path/symlink escape should fail closed without outside filesystem mutation。
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- Read Ticket item, routing IntentPacket, and implementation report。
|
||||||
|
- Inspected implementation commit metadata and relevant backend/runtime/frontend files in the implementation worktree only。
|
||||||
|
- Did not rerun reported cargo/deno/nix validations; Orchestrator-reported validation was passing, but blockers are functional/design acceptance gaps。
|
||||||
|
|
||||||
|
Non-blocking follow-ups:
|
||||||
|
- After blockers are fixed, add focused regression coverage for selected-root closure behavior, tree route smoke, Decodal editor extension wiring, UI create/delete flows, and symlinked-directory tree-write rejection-before-side-effect。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-09T08:52:14Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Review-fix implementation report:
|
||||||
|
|
||||||
|
- Coder Pod completed requested changes and committed follow-up `941b9126 fix: complete profile source tree review fixes` on branch `work/00001KX1JNJ2Y-profile-source-tree`.
|
||||||
|
- Worktree status was clean after commit。
|
||||||
|
- Fix summary:
|
||||||
|
- Added `decodal-codemirror@0.1.2` and wired `decodal()` into the CodeMirror 6 editor。
|
||||||
|
- Added source tree settings route `/w/<workspace-id>/settings/profiles/trees/<source-tree-id>`。
|
||||||
|
- Wired UI create/save/delete operations through Backend virtual-path source tree APIs。
|
||||||
|
- Changed archive construction to selected selector/root + import closure only, avoiding unrelated project tree sources。
|
||||||
|
- Added safe typed source/tree metadata including content type, content digest, and typed provenance。
|
||||||
|
- Hardened tree writes against symlinked intermediate directories before any outside filesystem mutation。
|
||||||
|
- Added focused regression coverage for selected-root closure-only archive contents, symlink write rejection before side effects, and web model smoke tests for tree routes / virtual create paths。
|
||||||
|
- Orchestrator reran validation in the implementation worktree:
|
||||||
|
- `git diff --check`: pass
|
||||||
|
- `cargo test -p yoi-workspace-server`: pass(93 lib tests + 2 main tests)
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests)
|
||||||
|
- `cargo check -p yoi`: pass
|
||||||
|
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||||
|
- `cd web/workspace && deno task test`: pass(19 tests)
|
||||||
|
- `yoi ticket doctor`: ok
|
||||||
|
- `nix build .#yoi --no-link`: pass
|
||||||
|
- Requesting follow-up external review against `81abfa63..941b9126` and full combined implementation。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-09T08:57:15Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
External follow-up review result: request_changes
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
|
||||||
|
1. Tree writes still allow a source-root symlink escape before/at filesystem mutation。
|
||||||
|
- Fix rejects symlinked intermediate directories under `.yoi/profiles`, but not `.yoi/profiles` itself being a symlink。
|
||||||
|
- `prepare_source_path_for_write()` creates/canonicalizes `source_root` without checking `symlink_metadata(source_root)` or verifying canonical source root remains under canonical workspace root。
|
||||||
|
- If `.yoi/profiles` is a symlink to an outside directory, `canonical_root` becomes outside target; `parent_relative` strips against textual symlink path and missing children can be created outside workspace。
|
||||||
|
- `checked_source_path()` repeats the escaped-root comparison and can accept files under escaped root。
|
||||||
|
- `list_profile_tree_files()` starts `read_dir` at `workspace_root/.yoi/profiles`, so symlinked source root can expose outside `.dcdl` files as virtual tree files。
|
||||||
|
- Required fix: reject symlinked source root and ensure canonical source root is confined under canonical workspace root before list/read/write/delete/archive operations。
|
||||||
|
|
||||||
|
2. Regression coverage for the remaining symlink escape is missing。
|
||||||
|
- Existing test covers symlinked child directory only。
|
||||||
|
- Add focused regression for `.yoi/profiles -> outside_dir` asserting tree write is rejected before side effects, outside target is not created/modified, and preferably list/read/archive construction also fail closed or do not traverse escaped root。
|
||||||
|
|
||||||
|
Evidence / resolved items:
|
||||||
|
- `decodal-codemirror@0.1.2` is present and `DecodalSourceEditor.svelte` imports/uses `decodal()` in CM6 extensions。
|
||||||
|
- Required tree route exists at `web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/...`; create/save/delete call Backend APIs。
|
||||||
|
- Selected-root archive construction now collects only import closure; tests assert unrelated sources are excluded。
|
||||||
|
- Browser-facing typed summaries include virtual path/root, content type/digest, and typed provenance without host paths。
|
||||||
|
- Runtime `ArchiveSourceLoader` remains manifest/import-map backed with no filesystem fallback。
|
||||||
|
|
||||||
|
Non-blocking follow-ups:
|
||||||
|
- Current web tests cover route helper/path normalization but not actual Svelte create/save/delete interaction behavior。
|
||||||
|
- Editor extension wiring is compile/static-source covered rather than directly unit-tested; acceptable for now, but smoke coverage would help future dependency regressions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-09T09:10:00Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Second review-fix implementation report:
|
||||||
|
|
||||||
|
- Coder Pod completed remaining requested changes and committed follow-up `13c6548b fix: reject profile source root symlinks` on branch `work/00001KX1JNJ2Y-profile-source-tree`。
|
||||||
|
- Worktree status was clean after commit。
|
||||||
|
- Fix summary:
|
||||||
|
- Added shared `ProfileSourceRoot` guard。
|
||||||
|
- Rejects `.yoi/profiles` when it is a symlink。
|
||||||
|
- Verifies canonical `.yoi/profiles` remains under the canonical workspace root。
|
||||||
|
- Applied the guard consistently to source tree listing, file read/delete checks, write preparation, source summaries, and archive construction through tree scan/read paths。
|
||||||
|
- Hardened writes so `.yoi` and `.yoi/profiles` are created one component at a time only after symlink/canonical containment checks。
|
||||||
|
- Added regression coverage for `.yoi/profiles -> outside_dir`: write rejects before outside side effects, outside target is not created/modified, source tree read/list fails closed, and archive construction fails closed。
|
||||||
|
- Orchestrator reran validation in the implementation worktree:
|
||||||
|
- `git diff --check`: pass
|
||||||
|
- `cargo test -p yoi-workspace-server`: pass(94 lib tests + 2 main tests)
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests)
|
||||||
|
- `cargo check -p yoi`: pass
|
||||||
|
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||||
|
- `cd web/workspace && deno task test`: pass(19 tests)
|
||||||
|
- `yoi ticket doctor`: ok
|
||||||
|
- `nix build .#yoi --no-link`: pass
|
||||||
|
- Requesting second follow-up external review against `941b9126..13c6548b` and full combined implementation。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-09T09:16:20Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
External second follow-up review result: approve
|
||||||
|
|
||||||
|
Blockers verified:
|
||||||
|
- Source-root symlink rejection is now in the shared source-root path validation:
|
||||||
|
- `existing_profile_source_root` rejects `.yoi/profiles` when the final component is a symlink。
|
||||||
|
- `prepare_profile_source_root_for_write` rejects both `.yoi` symlink parents and `.yoi/profiles` symlink roots before create/write operations。
|
||||||
|
- `checked_source_path`, tree listing, file read/write/delete, and archive construction all flow through these guards before touching tree contents。
|
||||||
|
- Regression coverage for `.yoi/profiles -> outside_dir` exists:
|
||||||
|
- `source_root_symlink_is_rejected_before_tree_side_effects` covers write rejection before outside side effects, `read_profile_source_tree` fail-closed behavior, and `build_workspace_profile_archive` fail-closed behavior。
|
||||||
|
- Existing symlink-parent / entry symlink tests remain in place for nested escape cases。
|
||||||
|
|
||||||
|
Still-good items checked and intact:
|
||||||
|
- Decodal editor wiring remains present via the Svelte editor component using `decodal()` / CodeMirror integration。
|
||||||
|
- Profile tree API routes remain wired for tree read and file create/save/delete:
|
||||||
|
- `/settings/profiles/trees/{source_tree_id}`
|
||||||
|
- `/settings/profiles/trees/{source_tree_id}/file`
|
||||||
|
- Virtual source tree summaries still expose virtual metadata/provenance/digest-type fields without host path disclosure。
|
||||||
|
- Selected-root archive construction still uses the selected profile entrypoint plus import closure rather than blindly archiving every source。
|
||||||
|
- `Runtime ArchiveSourceLoader` behavior remains bounded to archive manifest/import-map package files plus archive-backed imports; no host filesystem fallback was introduced。
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- Read Ticket records in `.yoi/tickets/00001KX1JNJ2Y/`, including latest implementation/review context。
|
||||||
|
- Inspected latest fix `13c6548b`, diff from `941b9126..13c6548b`, and relevant source files in the implementation worktree only。
|
||||||
|
- Reviewed the orchestrator-provided successful validation list; Reviewer did not rerun tests or modify files。
|
||||||
|
|
||||||
|
Non-blocking follow-up:
|
||||||
|
- A direct assertion for `read_profile_tree_file` against a root-symlinked `.yoi/profiles` would make the regression matrix even more explicit, but the implementation path already uses the same guarded `checked_source_path`, so this is not a merge blocker。
|
||||||
|
|
||||||
|
No merge-blocking issues found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-09T09:18:29Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Merge/validation acceptance:
|
||||||
|
|
||||||
|
- Second follow-up external review approved the implementation after symlink-root blocker fix。
|
||||||
|
- Implementation branch `work/00001KX1JNJ2Y-profile-source-tree` was merged into the orchestration branch with merge commit `9a2e57fb merge: profile source tree editor`。
|
||||||
|
- Ticket-record conflicts during merge were resolved by preserving Orchestrator-side Ticket item/thread records; product/code changes were merged。
|
||||||
|
- Final validation in Orchestrator worktree passed:
|
||||||
|
- `git diff --check`: pass
|
||||||
|
- `cargo test -p yoi-workspace-server`: pass(94 lib tests + 2 main tests)
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests)
|
||||||
|
- `cargo check -p yoi`: pass
|
||||||
|
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||||
|
- `cd web/workspace && deno task test`: pass(19 tests)
|
||||||
|
- `yoi ticket doctor`: ok
|
||||||
|
- `nix build .#yoi --no-link`: pass
|
||||||
|
|
||||||
|
Implementation commits:
|
||||||
|
- `81abfa63 feat: add profile source tree editor`
|
||||||
|
- `941b9126 fix: complete profile source tree review fixes`
|
||||||
|
- `13c6548b fix: reject profile source root symlinks`
|
||||||
|
|
||||||
|
No merge-blocking issues remain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-09T09:18:48Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-09T09:18:48Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
完了。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend-owned `ProfileSourceTree` virtual filesystem model を追加した。
|
||||||
|
- virtual relative paths / safe source tree ids / file ids / revision / content digest / content type / typed provenance / diagnostics を扱う safe summaries を追加した。
|
||||||
|
- Profile source tree list/read/write/create/delete/validate scoped APIs を追加した。
|
||||||
|
- virtual import resolver を追加し、relative imports / scoped namespace imports を扱い、absolute path、URL/unsupported scheme、root escape、unsupported namespace、limit excess を fail closed するようにした。
|
||||||
|
- `ProfileSourceArchive` construction を selected root/selector + source tree revision + import closure snapshot に変更し、unrelated sources を archive に含めないようにした。
|
||||||
|
- Runtime `ArchiveSourceLoader` / archive verification を virtual path / import-map only に強化し、Workspace filesystem/backend fallback を導入しないまま維持した。
|
||||||
|
- Settings UI に Profile source tree list と Decodal source editor route を追加した。
|
||||||
|
- `decodal-codemirror@0.1.2` と CodeMirror 6 の `decodal()` extension を editor に接続した。
|
||||||
|
- UI create/save/delete operations を Backend virtual-path APIs に接続した。
|
||||||
|
- `.yoi/profiles` source root symlink と symlink/path escape を list/read/write/delete/archive construction paths で reject し、outside filesystem mutation 前に fail closed するようにした。
|
||||||
|
- Focused backend/runtime/web tests を追加した。
|
||||||
|
|
||||||
|
主な commit / merge:
|
||||||
|
- implementation: `81abfa63 feat: add profile source tree editor`
|
||||||
|
- review fix: `941b9126 fix: complete profile source tree review fixes`
|
||||||
|
- symlink-root fix: `13c6548b fix: reject profile source root symlinks`
|
||||||
|
- merge into orchestration: `9a2e57fb merge: profile source tree editor`
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 external review は request_changes。
|
||||||
|
- first follow-up review も request_changes(source-root symlink blocker)。
|
||||||
|
- second follow-up review は approve。merge-blocking issue なし。
|
||||||
|
|
||||||
|
Final validation in Orchestrator worktree:
|
||||||
|
- `git diff --check`: pass
|
||||||
|
- `cargo test -p yoi-workspace-server`: pass(94 lib tests + 2 main tests)
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests)
|
||||||
|
- `cargo check -p yoi`: pass
|
||||||
|
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||||
|
- `cd web/workspace && deno task test`: pass(19 tests)
|
||||||
|
- `yoi ticket doctor`: ok
|
||||||
|
- `nix build .#yoi --no-link`: pass
|
||||||
|
|
||||||
|
補足:
|
||||||
|
- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。
|
||||||
|
- Reviewer の non-blocking follow-up: `read_profile_tree_file` に対する root-symlink direct assertion を追加すると regression matrix がさらに明示的になるが、同じ guarded `checked_source_path` を通るため merge blocker ではない。
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX6BPY7M/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX6BPY7M/artifacts/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"id":"orch-plan-20260710-161155-1","ticket_id":"00001KX6BPY7M","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KX6BPY7M は independent queued Ticket で blocker なし。Objective retention 方針に基づく Backend Worker/Workdir registry and link model を実装開始する。","branch":"work/00001KX6BPY7M-worker-workdir-registry","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6BPY7M-worker-workdir-registry","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Backend SQLite Worker/Workdir/link tables、Backend create/status sync integration、managed/unmanaged list projections、retention pinned metadata、tests/UI projection adjustments を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / raw-path redaction / registry authority / retention invariants に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-10T16:11:55Z"}
|
||||||
85
.yoi/tickets/00001KX6BPY7M/item.md
Normal file
85
.yoi/tickets/00001KX6BPY7M/item.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
title: 'Add Backend Worker/Workdir registry and link model'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-10T15:53:02Z'
|
||||||
|
updated_at: '2026-07-10T17:38:20Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'workspace-panel'
|
||||||
|
queued_at: '2026-07-10T16:10:57Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Objective `00001KWW44EXK` の Worker / Session / workdir retention 方針に基づき、Worker と workdir の保存・retention・link authority を Backend SQLite に置く。
|
||||||
|
|
||||||
|
現在は Runtime が Worker catalog と working directory materialization record を主に持ち、Backend は Worker create / workdir create を仲介しているだけである。このため、停止済み Worker、Session / transcript、Runtime-owned workdir、実ファイルが削除済みの workdir、unknown/external workdir の関係を Backend が安定して説明できない。
|
||||||
|
|
||||||
|
今後の prune / archive / pinned retention を安全に実装するには、先に Backend 側に canonical registry と relation を持たせる必要がある。
|
||||||
|
|
||||||
|
## 決定事項
|
||||||
|
|
||||||
|
- Worker を作業記録の保存単位にする。
|
||||||
|
- Session / transcript は Worker archive に内包または参照される履歴として扱い、Session 単体を長期保存 authority にしない。
|
||||||
|
- Worker archive には retention policy を持たせ、`pinned` Worker を cleanup / prune から守れるようにする。
|
||||||
|
- Workdir record は Backend SQLite にも canonical record を持たせる。Runtime は実ファイル、raw materialized path、process/cwd binding、cleanup 実行の authority を持つ。
|
||||||
|
- Worker と Workdir の関係は SQLite link table で表す。Worker 側 summary copy は許容するが、relation authority は link table とする。
|
||||||
|
- Workdir 実ファイルは durable history ではなく再現可能 cache として扱う。
|
||||||
|
- Dirty workdir は活動中状態として扱う。Dirty orphan は自動 prune ではなく recovery Worker の起動または explicit discard の判断対象にする。
|
||||||
|
- Browser-facing UI 表示は `workdir` に寄せる。内部 API の `working_directory` 互換名は段階移行中に残ってよい。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Backend SQLite に Worker registry を追加する。
|
||||||
|
- workspace_id
|
||||||
|
- backend worker id / runtime worker id
|
||||||
|
- runtime_id
|
||||||
|
- display name
|
||||||
|
- profile
|
||||||
|
- lifecycle state
|
||||||
|
- retention state(少なくとも pinned を表せること)
|
||||||
|
- session/transcript refs または将来 refs を保存できる shape
|
||||||
|
- summary/diagnostics 用の将来拡張余地
|
||||||
|
- Backend SQLite に Workdir registry を追加する。
|
||||||
|
- workspace_id
|
||||||
|
- workdir_id
|
||||||
|
- runtime_id
|
||||||
|
- repository_id
|
||||||
|
- selector
|
||||||
|
- resolved_commit
|
||||||
|
- materialization status(present/removed/missing/unknown 相当)
|
||||||
|
- cleanliness(clean/dirty/unknown 相当)
|
||||||
|
- created_at / updated_at
|
||||||
|
- Backend SQLite に Worker-Workdir link table を追加する。
|
||||||
|
- worker_id
|
||||||
|
- workdir_id
|
||||||
|
- role(primary など将来拡張可能)
|
||||||
|
- linked_at / unlinked_at
|
||||||
|
- Backend 経由の workdir create は Backend Workdir registry row を作成/更新してから Runtime materialization を要求する。
|
||||||
|
- Backend 経由の worker create は Backend Worker registry row と Worker-Workdir link を作成/更新する。
|
||||||
|
- Runtime から返る Worker / Workdir status は Backend registry に同期される。
|
||||||
|
- Browser-facing Worker / Workdir list は Backend registry を主にし、Runtime status は観測値として merge する。
|
||||||
|
- Runtime direct API などで作られた Backend registry に無い workdir は unmanaged/external として扱い、通常の Backend-managed Workdirs 一覧に混ぜない。
|
||||||
|
- Raw Runtime path は Backend registry / Browser response に保存・表示しない。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- Backend SQLite schema に Worker / Workdir / Worker-Workdir link の typed tables が追加されている。
|
||||||
|
- 既存 store からの migration / default path があり、既存 Workspace Backend 起動を壊さない。
|
||||||
|
- Backend API 経由で workdir を作成すると、Runtime record だけでなく Backend Workdir registry にも record が残る。
|
||||||
|
- Backend API 経由で Worker を作成すると、Backend Worker registry と Worker-Workdir link が残る。
|
||||||
|
- Stopped Worker と removed Workdir の関係を Backend registry から辿れる。
|
||||||
|
- Worker archive / retention metadata に `pinned` を表せる。
|
||||||
|
- Workdir 実ファイルが無いことは Runtime observation として扱われ、Backend canonical record が即座に失われない。
|
||||||
|
- Backend-managed Workdir と unmanaged/external Runtime Workdir を区別できる。
|
||||||
|
- Workers / Workdirs UI は Backend-managed registry を基準に表示できる。
|
||||||
|
- `cargo test -p yoi-workspace-server --lib` が通る。
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
|
||||||
|
- `cd web/workspace && deno task check && deno task test` が通る。
|
||||||
|
- `nix build .#yoi --no-link` が通る。
|
||||||
|
|
||||||
|
## 非目標
|
||||||
|
|
||||||
|
- 実際の prune/delete 実行はこの Ticket では扱わない。
|
||||||
|
- Session/transcript full deletion UI はこの Ticket では扱わない。
|
||||||
|
- Dirty orphan recovery Worker の自動起動はこの Ticket では扱わない。
|
||||||
|
- Runtime raw path を Backend canonical record に保存することはしない。
|
||||||
35
.yoi/tickets/00001KX6BPY7M/resolution.md
Normal file
35
.yoi/tickets/00001KX6BPY7M/resolution.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
Backend Worker/Workdir registry and link model を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend SQLite schema に `worker_registry`, `workdir_registry`, `worker_workdir_links` を追加。
|
||||||
|
- Worker registry / Workdir registry / Worker-Workdir link の typed store API と projection を追加。
|
||||||
|
- Backend 経由の Workdir 作成で Runtime materialization 前に Backend-managed pending row を作成。
|
||||||
|
- Backend / Runtime scoped Worker creation で Worker registry row、Workdir registry row、Worker-Workdir link を同期。
|
||||||
|
- Worker list は Runtime observation を同期したうえで Backend `worker_registry` / link authority から projection。
|
||||||
|
- Worker stop は canonical registry row を削除せず、Worker lifecycle と linked Workdir status を同期。
|
||||||
|
- Workdir list / launch options は Backend-managed rows を基準にし、`runtime_unmanaged` Workdirs は managed Browser list/options に混ぜず runtime/diagnostic projection で区別可能にした。
|
||||||
|
- `pinned` retention が通常 Runtime sync で `normal` に clobber されないよう保護。
|
||||||
|
- Browser-facing summaries に safe `management_kind` を追加し、raw Runtime materialized path を Backend registry / Browser response に保存・表示しない方針を維持。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は 5 blocker で `request_changes`。
|
||||||
|
- `8695089f fix: sync backend worker registry projections` で主要 blocker を修正。
|
||||||
|
- 2回目 review は unmanaged Workdir が managed list/options に混ざる blocker で `request_changes`。
|
||||||
|
- `239f93b0 fix: keep unmanaged workdirs out of managed lists` で残 blocker を修正。
|
||||||
|
- 最終 focused re-review は `approve`。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `03652f82 merge: worker workdir registry`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-workdir-final-validation-1783704956.txt`
|
||||||
|
|
||||||
|
Non-blocking follow-up noted by reviewer:
|
||||||
|
- Some runtime detail/create response paths still return direct Runtime summaries with `management_kind: None`; list/options surfaces required by this Ticket are safe, but a future cleanup can project detail responses through Backend records for more uniform diagnostics。
|
||||||
460
.yoi/tickets/00001KX6BPY7M/thread.md
Normal file
460
.yoi/tickets/00001KX6BPY7M/thread.md
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T15:53:02Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T15:54:24Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Worker / Session / workdir retention discussion from Objective 00001KWW44EXK:
|
||||||
|
|
||||||
|
- Worker is the archival unit; Session/transcript belongs under Worker archive.
|
||||||
|
- Worker retention must support pinned to protect selected history from cleanup/prune.
|
||||||
|
- Workdir files are cache when clean and reconstructable from repository + selector/resolved commit.
|
||||||
|
- Dirty workdir is an active/recovery state; dirty orphan should require recovery Worker or explicit discard, not automatic prune.
|
||||||
|
- Backend should keep canonical Worker/Workdir/link records in SQLite, while Runtime owns materialized files, raw paths, process/cwd binding, and cleanup execution.
|
||||||
|
- Runtime direct/unmanaged workdirs must be distinguishable from Backend-managed workdirs.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T16:06:07Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T16:06:07Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: workspace-panel at: 2026-07-10T16:10:57Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T16:11:45Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Ticket は Backend SQLite Worker registry / Workdir registry / Worker-Workdir link table の scope、retention/link authority、Backend-managed vs unmanaged Runtime workdir distinction、acceptance criteria を具体化している。
|
||||||
|
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。queued Ticket はこの Ticket 1件のみ、inprogress は 0 件。
|
||||||
|
- Objective `00001KWW44EXK` の retention 方針に沿った concrete implementation Ticket であり、実装前に必要な missing decision は見当たらない。
|
||||||
|
- queued notification is human authorized routing/start。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX6BPY7M)`: 0 件。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX6BPY7M)`: 0 件。
|
||||||
|
- Orchestrator worktree git status: clean on `orchestration`。
|
||||||
|
- queued Ticket 一覧: this Ticket 1件。inprogress は 0 件。
|
||||||
|
- Recent code history includes Runtime workdir pages / Worker launch / Execution Workspace materialization / Browser-managed execution workspaces already merged, giving implementation substrate。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
|
||||||
|
Intent:
|
||||||
|
- Add Backend-owned SQLite Worker registry, Workdir registry, and Worker-Workdir link authority so Backend can explain Worker/workdir retention and relationships independently of live Runtime catalog/path state。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- Worker is the archival/retention unit. Session/transcript refs belong under Worker archive/reference shape, not independent long-term authority。
|
||||||
|
- Workdir canonical record exists in Backend SQLite; Runtime owns actual files, raw materialized paths, process/cwd binding, cleanup execution。
|
||||||
|
- Worker-Workdir relation authority is a SQLite link table; summary copies are non-authoritative。
|
||||||
|
- Workdir files are reproducible cache, not durable history。
|
||||||
|
- Dirty workdir is active/recovery state; dirty orphan is not automatically pruned。
|
||||||
|
- Browser-facing UI uses `workdir` wording; internal `working_directory` compatibility names may remain during migration。
|
||||||
|
- Raw Runtime path must not be stored in Backend registry or shown in Browser responses。
|
||||||
|
- Runtime direct/unmanaged workdirs must be distinguishable from Backend-managed workdirs and not mixed into managed lists。
|
||||||
|
|
||||||
|
Requirements / acceptance criteria:
|
||||||
|
- Add typed Backend SQLite tables for Worker registry, Workdir registry, and Worker-Workdir links with migration/default path for existing workspaces。
|
||||||
|
- Backend workdir creation creates/updates Backend Workdir registry before requesting Runtime materialization。
|
||||||
|
- Backend Worker creation creates/updates Backend Worker registry and Worker-Workdir link。
|
||||||
|
- Runtime Worker / Workdir status observations sync into Backend registry without deleting canonical record when files disappear。
|
||||||
|
- Backend registry can answer stopped Worker -> removed/missing Workdir relationship。
|
||||||
|
- Worker archive/retention metadata can represent `pinned`。
|
||||||
|
- Browser Worker/Workdir lists are based on Backend-managed registry plus Runtime observation merge。
|
||||||
|
- Backend-managed vs unmanaged/external Runtime Workdirs are distinguishable。
|
||||||
|
|
||||||
|
Implementation latitude:
|
||||||
|
- Exact table names/enum names/API response shape/test helper layout can follow current `workspace-server` store/server style。
|
||||||
|
- Session/transcript refs can be future-compatible nullable/json-ish shape if full archive model is not yet implemented。
|
||||||
|
- Runtime observation sync can be minimal v0 as long as canonical Backend rows remain authoritative and unmanaged/external is typed。
|
||||||
|
|
||||||
|
Escalate if:
|
||||||
|
- Actual prune/delete execution, dirty orphan recovery Worker auto-start, session/transcript full deletion UI, or storing Runtime raw paths in Backend canonical records becomes necessary。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `git diff --check`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Current code map:
|
||||||
|
- Backend store/schema: `crates/workspace-server/src/store.rs`。
|
||||||
|
- Backend server/workdir/worker APIs: `crates/workspace-server/src/server.rs`, `hosts.rs`, `companion.rs`。
|
||||||
|
- Runtime workdir/worker models: `crates/worker-runtime/src/{runtime.rs,working_directory.rs,execution_workspace.rs,catalog.rs,http_server.rs}`。
|
||||||
|
- Frontend workdir/worker pages: `web/workspace/src/routes/w/[workspaceId]/...`, workspace sidebar/workdir/worker helpers。
|
||||||
|
|
||||||
|
Critical risks / reviewer focus:
|
||||||
|
- Do not store or expose raw Runtime materialized paths。
|
||||||
|
- Backend registry/link table must remain relation authority, not transient Runtime observation。
|
||||||
|
- Runtime direct unmanaged workdirs must be visible/diagnosable as unmanaged, not silently treated as Backend-managed。
|
||||||
|
- Missing/removed workdir files must not erase canonical Backend records。
|
||||||
|
- `pinned` retention must be representable and protected from future cleanup/prune surfaces。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T16:12:07Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Queued acceptance recorded after Workspace Dashboard Queue notification authorized Orchestrator routing.
|
||||||
|
|
||||||
|
Checked context:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX6BPY7M)`: blocking relation 0 件。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX6BPY7M)`: prior record 0 件。今回 accepted_plan を記録済み。
|
||||||
|
- Orchestrator worktree git status: clean on `orchestration`。
|
||||||
|
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
|
||||||
|
|
||||||
|
Acceptance basis:
|
||||||
|
- concrete missing decision / information は残っていない。
|
||||||
|
- Ticket scope is Backend Worker/Workdir registry/link authority; actual prune/delete/recovery automation and raw Runtime path persistence are explicitly out of scope。
|
||||||
|
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T16:13:15Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation routing update:
|
||||||
|
|
||||||
|
- Worktree-workflow に従い implementation worktree を作成した。
|
||||||
|
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KX6BPY7M-worker-workdir-registry`
|
||||||
|
- Branch: `work/00001KX6BPY7M-worker-workdir-registry`
|
||||||
|
- Worktree には tracked `.yoi` project records が見えている。
|
||||||
|
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
|
||||||
|
- Sibling Coder Pod `yoi-coder-00001KX6BPY7M-worker-workdir` を起動した。
|
||||||
|
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
|
||||||
|
- Coder には短い task と Ticket record 読み取り指示を渡した。
|
||||||
|
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T16:40:17Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod completed implementation and committed `3e5546ea feat: add backend worker workdir registry` on branch `work/00001KX6BPY7M-worker-workdir-registry`。
|
||||||
|
- Worktree status was clean after commit。
|
||||||
|
- Changed files include:
|
||||||
|
- `crates/workspace-server/src/store.rs`
|
||||||
|
- `crates/workspace-server/src/server.rs`
|
||||||
|
- `crates/worker-runtime/src/{catalog.rs,worker_backend.rs,working_directory.rs}`
|
||||||
|
- child implementation also committed `.yoi/tickets/00001KX6BPY7M/{item.md,thread.md}` changes; Orchestrator will preserve authoritative Ticket records during merge if conflicts occur。
|
||||||
|
- Implementation summary:
|
||||||
|
- Added Backend SQLite schema v3 tables: `worker_registry`, `workdir_registry`, `worker_workdir_links`。
|
||||||
|
- Added typed store records/APIs for Worker archival registry, Workdir registry, Worker-Workdir link authority, and explicit `pinned` retention representation。
|
||||||
|
- Backend-managed Workdir IDs and pending registry writes are created before Runtime materialization。
|
||||||
|
- Browser Workdir lists are based on Backend-managed registry rows。
|
||||||
|
- Runtime-direct Workdirs are distinguished as `runtime_unmanaged`。
|
||||||
|
- Canonical Workdir records are preserved after cleanup/removal。
|
||||||
|
- Worker creation/listing records Backend Worker registry entries and links selected Workdirs without storing raw Runtime materialized paths。
|
||||||
|
- Extended worker-runtime `WorkingDirectoryRequest` with optional `backend_workdir_id`。
|
||||||
|
- Orchestrator reran validation in the implementation worktree:
|
||||||
|
- `git diff --check`: pass
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`: pass(95 tests)
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(48 lib tests + 5 main tests + doc tests)
|
||||||
|
- `cargo check -p yoi`: pass
|
||||||
|
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||||
|
- `cd web/workspace && deno task test`: pass(23 tests)
|
||||||
|
- `yoi ticket doctor`: ok
|
||||||
|
- `nix build .#yoi --no-link`: pass
|
||||||
|
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T16:52:25Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
External review result: request_changes
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
|
||||||
|
1. Browser Worker list が Backend registry based になっていない。
|
||||||
|
- Ticket は Worker/Workdir lists を Backend-managed registry + Runtime observation merge に基づかせることを要求している。
|
||||||
|
- `workers_response` はまだ `api.runtime.list_workers(limit)` を呼び、live Runtime items を opportunistic に記録したうえで `items: runtime_workers.items` を返している(`crates/workspace-server/src/server.rs:2529-2546`)。
|
||||||
|
- `worker_registry` / `worker_workdir_links` を read していないため、Backend-only / stopped / archive rows を list できず、relation authority が Browser Worker list に使われていない。
|
||||||
|
|
||||||
|
2. Runtime Worker creation path が Worker registry / Workdir registry / links を書いていない。
|
||||||
|
- `create_runtime_worker` は `working_directory_request` を解決して `api.runtime.spawn_worker` に forward し、Runtime result を返すだけ(`server.rs:2269-2285`)。
|
||||||
|
- Runtime materialization 前の pending Backend Workdir row、`record_worker_summary`、Worker-Workdir link がない。
|
||||||
|
- Browser `/api/workers` path のみ existing Workdir を record/link-select している(`server.rs:2141-2170`)。
|
||||||
|
- Backend-created/internal Runtime Workers with materialized workdirs が untracked になり、「Backend worker creation writes/updates Worker registry and Worker-Workdir link」 acceptance に違反する。
|
||||||
|
|
||||||
|
3. Stopped Worker -> removed/missing Workdir relationship が Backend registry から安定して答えられない。
|
||||||
|
- `stop_runtime_worker` は Runtime stop を forward して返すだけ(`server.rs:2330-2339`)。
|
||||||
|
- Runtime cleanup 後に Worker lifecycle、Workdir materialization status、link state を Backend SQLite に更新しない。
|
||||||
|
- `working_directory_summaries` は `list_managed_workdir_registry` だけを読む(`server.rs:3242-3249`)ため、Runtime stop が bound workdir を remove しても Backend record は stale `present` のままになり得る。
|
||||||
|
|
||||||
|
4. Runtime Workdir observation / unmanaged distinction が partial。
|
||||||
|
- runtime-scoped workdir list は raw Runtime summaries を直接返す(`server.rs:1184-1200`)。
|
||||||
|
- workspace-scoped list は Backend-managed rows のみ返す。
|
||||||
|
- `runtime_unmanaged` rows は detail / worker-create paths で opportunistic に作られるだけで、list 時に sync/typed されない。
|
||||||
|
- Browser summaries も management kind を露出していないため、unmanaged/external Runtime Workdirs を Backend-managed Workdirs と区別して visible/diagnosable にする requirement を満たしていない。
|
||||||
|
|
||||||
|
5. pinned retention が sync で clobber され得る。
|
||||||
|
- schema は `pinned` を保存できるが、`record_worker_summary` は常に `retention_state: "normal"` を書く(`server.rs:3290-3318`)。
|
||||||
|
- `upsert_worker_registry` は conflict 時に `retention_state = excluded.retention_state` で上書きする(`store.rs:223-242`)。
|
||||||
|
- pinned Worker への後続 Runtime observation / list sync が pinned を normal に downgrade できるため、`pinned` が durable retention metadata として保護されていない。
|
||||||
|
|
||||||
|
Non-blocking follow-ups:
|
||||||
|
- schema 方向性自体は妥当。`worker_registry`, `workdir_registry`, `worker_workdir_links` は typed checks / FK relations 付きで存在する(`store.rs:488-545`)。
|
||||||
|
- Backend workdir creation が Runtime materialization 前に pending Backend row を作る点は良い(`server.rs:1276-1327`)。
|
||||||
|
- inspected paths では raw materialized Runtime paths が新 Backend registry records に保存されている箇所は見当たらなかった。
|
||||||
|
- tests は schema roundtrip / pinned storage / removed Workdir / link basics を cover しているが、上記 blocker paths(runtime worker spawn with workdir, stop cleanup syncing, Backend-registry Worker list projection, unmanaged list diagnostics, pinned preservation under sync)は不足。
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- Ticket item/thread including routing decision, IntentPacket, implementation report を確認。
|
||||||
|
- implementation commit `3e5546ea` の `git show --name-only` / `git show --check` を確認。
|
||||||
|
- implementation worktree 内だけで branch/status を確認。
|
||||||
|
- `store.rs`, `server.rs`, `catalog.rs`, `worker_backend.rs`, `working_directory.rs` を review。
|
||||||
|
- acceptance blockers が inspection で明確だったため、full test/build matrix は再実行していない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T17:14:02Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up implementation progress report after request_changes:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6BPY7M-worker-workdir` completed the focused fix pass and committed follow-up commit `8695089fba346b8f10d55016b8328337e1c37445 fix: sync backend worker registry projections` on branch `work/00001KX6BPY7M-worker-workdir-registry`。
|
||||||
|
- Implementation worktree status was clean at Orchestrator inspection。
|
||||||
|
- Orchestrator inspected branch log / commit stats / `git diff --check 86f652a7..HEAD` in the implementation worktree。
|
||||||
|
|
||||||
|
Coder-reported fix summary:
|
||||||
|
- Browser Worker list now projects from Backend `worker_registry` rows after Runtime observation sync, preserving Backend-only stopped/archive rows。
|
||||||
|
- Worker-Workdir relationships are projected from `worker_workdir_links` as authority。
|
||||||
|
- Runtime-scoped Worker spawn now writes pending Backend-managed Workdir rows, Worker registry rows, and Worker-Workdir links。
|
||||||
|
- Worker stop now updates Backend Worker lifecycle and syncs linked Workdir status to present/removed/missing without deleting canonical rows。
|
||||||
|
- Runtime Workdir observation/listing now syncs Runtime observations into Backend registry and projects safe `management_kind` (`backend_managed` / `runtime_unmanaged`)。
|
||||||
|
- Existing `pinned` Worker retention is protected from ordinary Runtime sync/upsert clobbering。
|
||||||
|
- Tests were added/strengthened for archive/link projection, unmanaged Workdir projection, raw path redaction, removed Workdir preservation, and pinned retention preservation。
|
||||||
|
- Frontend type exposure for Workdir `management_kind` was added。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Request focused external re-review against commits `3e5546ea..8695089f` and the whole Ticket acceptance criteria before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T17:22:14Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
Focused re-review result: request_changes
|
||||||
|
|
||||||
|
Evidence reviewed:
|
||||||
|
- Implementation worktree is on `work/00001KX6BPY7M-worker-workdir-registry` at `8695089fba346b8f10d55016b8328337e1c37445`, clean。
|
||||||
|
- Follow-up fixes most prior blockers:
|
||||||
|
- Worker list now syncs Runtime observations, then projects from `worker_registry` / `worker_workdir_links` (`workers_response`, `merge_worker_registry_projection`)。
|
||||||
|
- Runtime Worker create now writes pending Backend Workdir rows, records Worker rows, and links selected/materialized Workdirs。
|
||||||
|
- Stop path now updates Worker lifecycle and attempts linked Workdir status sync。
|
||||||
|
- Store upsert preserves existing `pinned` when a Runtime sync supplies `normal`。
|
||||||
|
- New registry records still do not store raw materialized Runtime paths。
|
||||||
|
|
||||||
|
Blocker:
|
||||||
|
- Unmanaged Runtime Workdirs are now mixed into normal Backend-managed Browser lists / launch options。
|
||||||
|
- Ticket requires Runtime-direct/unmanaged Workdirs to be distinguishable and “not mixed into managed lists” (`item.md:61`)。
|
||||||
|
- Follow-up syncs unknown Runtime Workdirs as `runtime_unmanaged`, but `working_directory_summaries()` returns all `workdir_registry` rows via `list_workdir_registry()` (`server.rs:3300-3308`)。
|
||||||
|
- This feeds Browser launch options (`server.rs:3278`) and the workspace-scoped Workdirs list。
|
||||||
|
- The worker-new UI select iterates `options.working_directories` without filtering or labeling `management_kind`, so a `runtime_unmanaged` Workdir can appear/select like a normal managed Workdir。
|
||||||
|
- This is not a safe typed UI distinction and does not satisfy the Backend-managed vs unmanaged boundary。
|
||||||
|
|
||||||
|
Non-blocking notes:
|
||||||
|
- Runtime-scoped Workdir lists being typed from registry is a good direction。
|
||||||
|
- Consider ensuring detail/cleanup/create responses also project through `workdir_summary_from_record`; several paths still return Runtime summaries with `management_kind: None`, even after storing a typed Backend record。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T17:32:02Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Second follow-up implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6BPY7M-worker-workdir` completed the focused unmanaged-workdir fix and committed `239f93b084fdcc2201e17fcdbc23eb0140531f16 fix: keep unmanaged workdirs out of managed lists` on branch `work/00001KX6BPY7M-worker-workdir-registry`。
|
||||||
|
- Implementation worktree status was clean at Orchestrator inspection。
|
||||||
|
- Orchestrator inspected branch log / commit stats / `git diff --check 86f652a7..HEAD` in the implementation worktree。
|
||||||
|
|
||||||
|
Coder-reported fix summary:
|
||||||
|
- Workspace-scoped Browser Workdir summaries / launch options now include only `backend_managed` Workdir registry rows。
|
||||||
|
- Runtime-direct/unmanaged Workdirs remain visible through runtime-scoped projection with explicit `management_kind: runtime_unmanaged`。
|
||||||
|
- Tests prove:
|
||||||
|
- `runtime_unmanaged` rows are excluded from managed Browser Workdir summaries / launch options。
|
||||||
|
- unmanaged rows remain distinguishable in runtime projection。
|
||||||
|
- store-level managed listing excludes unmanaged rows while all-row listing still includes them。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Request focused re-review on remaining unmanaged-list blocker and full acceptance status before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T17:35:29Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Focused re-review result: approve
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Follow-up `239f93b084fdcc2201e17fcdbc23eb0140531f16` is present on the requested branch/worktree; worktree was clean during inspection。
|
||||||
|
- Remaining blocker is fixed:
|
||||||
|
- Workspace-scoped Browser launch options still use `working_directory_summaries()`, and that now lists only `backend_managed` rows after Runtime observation sync (`server.rs:3279-3308`)。
|
||||||
|
- Store filtering is explicit in `list_managed_workdir_registry()` with `management_kind = 'backend_managed'` (`store.rs:376-392`)。
|
||||||
|
- Runtime-scoped Workdir projection still lists registry rows for the Runtime and preserves explicit `management_kind`, so `runtime_unmanaged` remains diagnosable (`server.rs:3310-3317`, `workdir_summary_from_record`)。
|
||||||
|
- Previous blockers sanity-checked:
|
||||||
|
- Worker list syncs Runtime observations then projects from Backend `worker_registry` / link authority (`server.rs:2564-2583`, `merge_worker_registry_projection`)。
|
||||||
|
- Runtime Worker create prepares pending Workdir rows, syncs Worker observations, and links Workdirs (`server.rs:2295-2352`)。
|
||||||
|
- Stop path preserves canonical records and updates Worker/linked Workdir statuses without deleting registry rows (`server.rs:2399-2443`)。
|
||||||
|
- Pinned retention is protected from normal Runtime sync by store upsert `CASE` logic (`store.rs:220-243`)。
|
||||||
|
- New Backend registry structs/schema store ids/status/metadata only; inspected Browser summaries do not expose raw Runtime materialized paths。
|
||||||
|
- Focused validation run:
|
||||||
|
- `cargo test -p yoi-workspace-server --lib workdir -- --nocapture` passed 3/3 relevant tests, including managed-list exclusion, unmanaged runtime projection, and pinned/registry roundtrip coverage。
|
||||||
|
|
||||||
|
Non-blocking note:
|
||||||
|
- Some runtime detail/create response paths still return direct Runtime summaries with `management_kind: None`; list/options surfaces required by this Ticket are now safe, but projecting details through Backend records would make diagnostics more uniformly typed later。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T17:38:03Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Merge / final validation report:
|
||||||
|
|
||||||
|
- Reviewer approved the implementation after the final focused fix。
|
||||||
|
- Merged branch `work/00001KX6BPY7M-worker-workdir-registry` into orchestration branch with merge commit `03652f82 merge: worker workdir registry`。
|
||||||
|
- Ticket record conflicts from child branch were resolved in favor of Orchestrator-authoritative Ticket records; implementation code changes were merged。
|
||||||
|
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Validation log:
|
||||||
|
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-workdir-final-validation-1783704956.txt`
|
||||||
|
|
||||||
|
Reviewed/merged implementation commits:
|
||||||
|
- `3e5546ea feat: add backend worker workdir registry`
|
||||||
|
- `8695089f fix: sync backend worker registry projections`
|
||||||
|
- `239f93b0 fix: keep unmanaged workdirs out of managed lists`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T17:38:06Z from: inprogress to: done reason: implementation_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T17:38:20Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T17:38:20Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Backend Worker/Workdir registry and link model を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend SQLite schema に `worker_registry`, `workdir_registry`, `worker_workdir_links` を追加。
|
||||||
|
- Worker registry / Workdir registry / Worker-Workdir link の typed store API と projection を追加。
|
||||||
|
- Backend 経由の Workdir 作成で Runtime materialization 前に Backend-managed pending row を作成。
|
||||||
|
- Backend / Runtime scoped Worker creation で Worker registry row、Workdir registry row、Worker-Workdir link を同期。
|
||||||
|
- Worker list は Runtime observation を同期したうえで Backend `worker_registry` / link authority から projection。
|
||||||
|
- Worker stop は canonical registry row を削除せず、Worker lifecycle と linked Workdir status を同期。
|
||||||
|
- Workdir list / launch options は Backend-managed rows を基準にし、`runtime_unmanaged` Workdirs は managed Browser list/options に混ぜず runtime/diagnostic projection で区別可能にした。
|
||||||
|
- `pinned` retention が通常 Runtime sync で `normal` に clobber されないよう保護。
|
||||||
|
- Browser-facing summaries に safe `management_kind` を追加し、raw Runtime materialized path を Backend registry / Browser response に保存・表示しない方針を維持。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は 5 blocker で `request_changes`。
|
||||||
|
- `8695089f fix: sync backend worker registry projections` で主要 blocker を修正。
|
||||||
|
- 2回目 review は unmanaged Workdir が managed list/options に混ざる blocker で `request_changes`。
|
||||||
|
- `239f93b0 fix: keep unmanaged workdirs out of managed lists` で残 blocker を修正。
|
||||||
|
- 最終 focused re-review は `approve`。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `03652f82 merge: worker workdir registry`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-workdir-final-validation-1783704956.txt`
|
||||||
|
|
||||||
|
Non-blocking follow-up noted by reviewer:
|
||||||
|
- Some runtime detail/create response paths still return direct Runtime summaries with `management_kind: None`; list/options surfaces required by this Ticket are safe, but a future cleanup can project detail responses through Backend records for more uniform diagnostics。
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX6CRVBE/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX6CRVBE/artifacts/.gitkeep
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{"id":"orch-plan-20260710-164538-1","ticket_id":"00001KX6CRVBE","kind":"after","related_ticket":"00001KX6BPY7M","note":"Ticket body explicitly states this is a follow-up to `00001KX6BPY7M`. The prerequisite registry/link model is currently inprogress on branch `work/00001KX6BPY7M-worker-workdir-registry` and under external review. Start this cleanup/delete Ticket only after that branch is approved/merged or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T16:45:38Z"}
|
||||||
|
{"id":"orch-plan-20260710-164549-2","ticket_id":"00001KX6CRVBE","kind":"waiting_capacity_note","note":"Queue review after Dashboard authorization. `00001KX6CRVBE` is otherwise well specified, but it depends on the Backend Worker/Workdir registry/link model from `00001KX6BPY7M`, which is currently inprogress/review. Starting now would duplicate/conflict with store schema, server APIs, and Worker/Workdir projections. Leave queued until `00001KX6BPY7M` is approved/merged; then re-route/start from updated orchestration branch.","author":"orchestrator","at":"2026-07-10T16:45:49Z"}
|
||||||
|
{"id":"orch-plan-20260710-173850-3","ticket_id":"00001KX6CRVBE","kind":"accepted_plan","accepted_plan":{"summary":"Implement manual Worker/Workdir delete and cleanup operations on top of the merged Backend Worker/Workdir registry/link model. Keep cleanup manual-first with stale revision/digest checks, pinned protection, running-linked Workdir blocks, dirty discard confirmation, raw path redaction, and Browser plan preview/execution surfaces.","branch":"work/00001KX6CRVBE-manual-cleanup","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6CRVBE-manual-cleanup","role_plan":"Orchestrator accepts queued Ticket after prerequisite `00001KX6BPY7M` was approved, merged, validated, closed, and its implementation worktree/branch cleaned. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/validation/close authority."},"author":"orchestrator","at":"2026-07-10T17:38:50Z"}
|
||||||
78
.yoi/tickets/00001KX6CRVBE/item.md
Normal file
78
.yoi/tickets/00001KX6CRVBE/item.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
title: 'Add manual delete and cleanup operations for Workers and workdirs'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-10T16:11:33Z'
|
||||||
|
updated_at: '2026-07-10T18:37:40Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'workspace-panel'
|
||||||
|
queued_at: '2026-07-10T16:45:14Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Ticket `00001KX6BPY7M` で Backend SQLite に Worker / Workdir registry と Worker-Workdir link を追加する。その後続として、停止済み Worker、Session / transcript、Workdir 実ファイル、removed / missing Workdir record を安全に整理する manual delete / cleanup の仕組みが必要になる。
|
||||||
|
|
||||||
|
削除方針は基本手動とする。Yoi が勝手に Worker history や workdir changes を削除しない。Backend は削除候補と理由を plan として提示し、ユーザーまたは明示的な orchestration authority が実行した場合だけ削除する。
|
||||||
|
|
||||||
|
## 方針
|
||||||
|
|
||||||
|
- Worker lifecycle はシンプルに `running -> stopped -> delete` とする。`archived` を lifecycle state として導入しない。
|
||||||
|
- Worker は保存単位であり、Session / transcript は Worker に内包または参照される履歴として扱う。
|
||||||
|
- Worker delete は Worker record と内包する Session / transcript history の削除を意味する。Workdir は自動削除しない。
|
||||||
|
- `pinned` Worker は delete 候補にしてはならない。この Ticket で pinned flag の永続化と pin/unpin mutation API も明示的に実装する。
|
||||||
|
- Session / transcript の圧縮保存や高度な archive storage policy はこの Ticket では扱わない。必要なら後続で `history_storage: full | summarized | deleted` のような別概念として扱う。
|
||||||
|
- Workdir 実ファイルは durable history ではなく再現可能 cache として扱い、基本的に手動で削除可能にする。
|
||||||
|
- Clean Workdir は、running Worker に紐づいていなければ manual cleanup 候補にできる。
|
||||||
|
- Dirty Workdir は削除可能だが、changes ごと消す explicit confirmation を要求する。
|
||||||
|
- Dirty orphan は recovery Worker 起動または explicit discard の判断対象であり、通常の clean cleanup とは区別する。
|
||||||
|
- Removed / missing Workdir record は、linked Worker が最後の repository/selector/resolved commit summary を保持している場合、manual record delete 候補にできる。
|
||||||
|
- Raw Runtime path は Browser-facing plan に出さない。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Backend registry の Worker record に pinned flag または equivalent retention field を追加し、永続化する。
|
||||||
|
- Backend API に Worker pin / unpin mutation を追加する。mutation は Backend Worker registry を更新し、Runtime process には不要な副作用を起こさない。
|
||||||
|
- Backend registry を基準に Worker / Workdir / link の manual cleanup plan を生成する。
|
||||||
|
- Runtime observation を merge して、workdir files の状態を `present` / `removed` / `missing` / `unknown` 相当で表示する。
|
||||||
|
- Plan は対象ごとに、削除可能性、blocking reason、削除種別、推定 reclaim bytes を返す。
|
||||||
|
- Manual operation として少なくとも以下を表せる。
|
||||||
|
- Worker delete(Worker record +内包 Session / transcript history)
|
||||||
|
- Workdir files cleanup
|
||||||
|
- Dirty Workdir discard with explicit confirmation
|
||||||
|
- Removed/missing Workdir record delete
|
||||||
|
- cleanup_pending retry
|
||||||
|
- 実行 API は対象 ID と expected plan revision / digest を受け取り、古い plan に基づく削除を拒否する。
|
||||||
|
- `pinned` Worker とそれに紐づく Session history は削除実行時にも保護される。
|
||||||
|
- Running Worker に紐づく Workdir files cleanup は拒否される。
|
||||||
|
- Dirty Workdir の discard は通常 cleanup とは別 action として明示される。
|
||||||
|
- Browser UI は plan preview を表示し、ユーザーが明示的に選択した対象だけ実行できる。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `00001KX6BPY7M` の Backend Worker/Workdir registry と link model を前提として実装されている。
|
||||||
|
- Backend API で Worker を pin / unpin でき、状態が SQLite に永続化される。
|
||||||
|
- Worker list/detail は pinned state を返す。
|
||||||
|
- Backend API で Runtime ごとの manual cleanup plan を取得できる。
|
||||||
|
- Plan に Worker delete candidates と Workdir cleanup/delete candidates が分かれて表示される。
|
||||||
|
- Candidate には削除種別、理由、blocking reason、linked Worker/Workdir ids、pinned state が含まれる。
|
||||||
|
- `pinned` Worker は delete/prune candidate にならない。
|
||||||
|
- Running Worker に linked された Workdir files は cleanup candidate にならない。
|
||||||
|
- Clean + no running linked Worker の Workdir files は cleanup candidate になる。
|
||||||
|
- Dirty Workdir は discard confirmation required として表示され、通常 cleanup と区別される。
|
||||||
|
- Removed + no retained link の Workdir record は record delete candidate になる。
|
||||||
|
- Manual cleanup/delete 実行 API は stale plan revision を拒否する。
|
||||||
|
- Manual cleanup/delete 実行後、Backend registry と Runtime observation の状態が更新される。
|
||||||
|
- UI は Runtime > Workdirs / Workers 周辺から plan preview と manual execution に到達できる。
|
||||||
|
- Raw Runtime path が Browser-facing response / UI に出ない。
|
||||||
|
- `cargo test -p yoi-workspace-server --lib` が通る。
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
|
||||||
|
- `cd web/workspace && deno task check && deno task test` が通る。
|
||||||
|
- `nix build .#yoi --no-link` が通る。
|
||||||
|
|
||||||
|
## 非目標
|
||||||
|
|
||||||
|
- 容量や期限による自動 prune policy は扱わない。
|
||||||
|
- `archived` Worker lifecycle state は導入しない。
|
||||||
|
- Session / transcript の圧縮 archive storage は扱わない。
|
||||||
|
- Dirty Workdir を自動的に commit/push する recovery Worker 実装は扱わない。
|
||||||
|
- Runtime raw path を Backend canonical record に保存することはしない。
|
||||||
31
.yoi/tickets/00001KX6CRVBE/resolution.md
Normal file
31
.yoi/tickets/00001KX6CRVBE/resolution.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
Manual Worker/Workdir delete and cleanup operations を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend Worker registry row の pin/unpin API を追加し、Worker list/detail に `pinned` / `retention_state` を返すようにした。
|
||||||
|
- Runtime ごとの manual cleanup plan API を追加し、Backend Worker/Workdir/link registry と Runtime observation に基づいて Worker delete / Workdir cleanup/delete candidates を生成するようにした。
|
||||||
|
- Plan candidate は action kind、reason/blocking reason、linked Worker/Workdir ids、pinned state、cleanliness/file status、running-link status、safe な reclaim bytes placeholder を含む。
|
||||||
|
- Manual cleanup execution API は expected plan revision/digest を要求し、stale plan を拒否する。
|
||||||
|
- Pinned Worker/history delete、running-linked Workdir cleanup、dirty/unknown Workdir の confirmation なし discard を拒否する。
|
||||||
|
- Removed/missing Workdir registry record delete は安全条件のもとで separate action として扱う。
|
||||||
|
- Runtime-observed/materialized Workdirs は trusted clean evidence なしに `clean` とせず、`unknown` として扱い、normal clean cleanup ではなく explicit discard confirmation path に乗せる。
|
||||||
|
- Browser UI に Runtime Workdirs cleanup preview/execution と Worker pin/unpin controls を追加した。
|
||||||
|
- UI では verified-clean cleanup と dirty/unknown-state discard を区別し、raw Runtime materialized path を表示しない。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は dirty Workdir safety の実データ経路が不十分として `request_changes`。
|
||||||
|
- `361569a6 fix: require discard confirmation for unknown workdirs` で observed Workdir を `unknown` 扱いにし、unknown/dirty cleanup に explicit confirmation を要求するよう修正。
|
||||||
|
- focused re-review は `approve`。
|
||||||
|
- `estimated_reclaim_bytes: None` は safe size source が未実装のため許容と判断された。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `4970a58c merge: manual worker workdir cleanup`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/manual-cleanup-final-validation-1783708592.txt`
|
||||||
359
.yoi/tickets/00001KX6CRVBE/thread.md
Normal file
359
.yoi/tickets/00001KX6CRVBE/thread.md
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T16:11:33Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T16:12:30Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Follow-up to 00001KX6BPY7M. Deletion/cleanup policy is intentionally manual-first:
|
||||||
|
|
||||||
|
- No automatic prune based on age or capacity in this ticket.
|
||||||
|
- Backend presents a plan with reasons, blockers, linked Worker/Workdir ids, retention, pinned state, and expected revision/digest.
|
||||||
|
- User or explicit orchestration authority selects targets and executes cleanup.
|
||||||
|
- Pinned Worker history is protected.
|
||||||
|
- Clean workdir files are cache and may be manually cleaned when not linked to running Workers.
|
||||||
|
- Dirty orphan workdirs require recovery/discard decision, not normal prune.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T16:31:05Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Clarification: because 00001KX6BPY7M is already in progress, this follow-up ticket explicitly owns implementation of Worker pinned state beyond merely consuming it in prune logic.
|
||||||
|
|
||||||
|
Scope added here:
|
||||||
|
- Persist pinned flag or equivalent Worker retention field in Backend registry.
|
||||||
|
- Add Backend Worker pin/unpin mutation API.
|
||||||
|
- Include pinned state in Worker list/detail and prune plan.
|
||||||
|
- Ensure manual cleanup execution rejects pinned Worker history and protected linked records.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T16:43:34Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Lifecycle clarification:
|
||||||
|
|
||||||
|
- Worker lifecycle should stay simple: running -> stopped -> delete.
|
||||||
|
- Do not introduce archived as a Worker lifecycle state in this ticket.
|
||||||
|
- Worker is the storage unit for Session/transcript history; deleting a Worker deletes or removes access to that history.
|
||||||
|
- Pinned remains necessary and blocks Worker deletion.
|
||||||
|
- Workdir files are cache and should be manually deletable.
|
||||||
|
- Dirty workdir deletion is allowed only with explicit confirmation that changes will be discarded.
|
||||||
|
- Compression/archive storage for Session history is out of scope and should be a separate storage policy if needed later.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T16:44:17Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T16:44:17Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: workspace-panel at: 2026-07-10T16:45:14Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T16:46:04Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: blocked_by_dependency_or_missing_authority
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Ticket is a manual delete / cleanup follow-up that depends on the Backend Worker/Workdir registry and link authority from `00001KX6BPY7M`。
|
||||||
|
- `00001KX6BPY7M` is currently `inprogress` on branch `work/00001KX6BPY7M-worker-workdir-registry` and under external review。
|
||||||
|
- This Ticket touches the same store schema, server APIs, Worker/Workdir projections, retention/managed-unmanaged semantics, and cleanup safety rules。
|
||||||
|
- Starting this Ticket now would create a high-conflict parallel branch and could implement delete/cleanup behavior against an unreviewed registry model。
|
||||||
|
- Therefore this routing pass leaves the Ticket queued and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX6CRVBE)`: 0 typed relations, but Ticket body/order context makes `00001KX6BPY7M` a practical implementation prerequisite。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX6CRVBE)`: prior record 0 件だったため、今回 `after 00001KX6BPY7M` と waiting-capacity note を記録。
|
||||||
|
- `TicketList`: queued はこの Ticket 1件、inprogress は `00001KX6BPY7M` 1件。
|
||||||
|
- Orchestrator worktree git status: clean on `orchestration`。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- `00001KX6BPY7M` の implementation/review/merge outcome を待つ。
|
||||||
|
- registry/link model が merge されたら、この Ticket を再 routing し、updated orchestration branch から start する。
|
||||||
|
|
||||||
|
Escalate if:
|
||||||
|
- 人間が `00001KX6BPY7M` とこの Ticket を同一 combined worktree で統合実装する方針に切り替えたい場合。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T17:39:00Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: accept for implementation
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Previously recorded prerequisite `00001KX6BPY7M` has now been reviewed, merged into orchestration branch, final-validated, closed, and its child implementation worktree/branch cleaned up。
|
||||||
|
- `TicketRelationQuery(00001KX6CRVBE)` shows no typed blockers。
|
||||||
|
- Queue contains this Ticket and there are no other `inprogress` Tickets。
|
||||||
|
- Ticket is concrete and well scoped: manual Worker delete / Workdir cleanup plan and execution on top of Backend Worker/Workdir registry/link authority。
|
||||||
|
|
||||||
|
Accepted plan:
|
||||||
|
- Create dedicated implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX6CRVBE-manual-cleanup` on branch `work/00001KX6CRVBE-manual-cleanup` from current orchestration branch。
|
||||||
|
- Route implementation to sibling Coder Pod。
|
||||||
|
- Route external review to sibling Reviewer Pod after Coder commit/validation。
|
||||||
|
- Orchestrator retains merge, final validation, Ticket close, and cleanup authority。
|
||||||
|
|
||||||
|
Design constraints for implementation:
|
||||||
|
- Cleanup/delete is manual-first only; no automatic prune policy。
|
||||||
|
- Worker lifecycle remains `running -> stopped -> delete`; do not introduce `archived` lifecycle state。
|
||||||
|
- `pinned` Worker/history is protected in plan generation and execution。
|
||||||
|
- Workdir file cleanup blocks when linked to running Workers。
|
||||||
|
- Dirty Workdir discard is explicit and separate from ordinary clean cleanup。
|
||||||
|
- Execution must reject stale plan revision/digest。
|
||||||
|
- Browser-facing responses/UI must not expose raw Runtime paths。
|
||||||
|
- Workdir/Worker canonical records should remain Backend/registry based and safe after execution。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T17:39:03Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Prerequisite registry/link model is now closed and merged. Accepting queued manual cleanup Ticket for implementation before creating worktree or spawning role Pods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T18:13:40Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6CRVBE-manual-cleanup` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6CRVBE-manual-cleanup` on branch `work/00001KX6CRVBE-manual-cleanup`。
|
||||||
|
- Implementation commit: `ba44391acfdb8076c06ced9324b74d6cc63910ab feat: add manual worker workdir cleanup`。
|
||||||
|
- Orchestrator inspected the worktree status, branch log, commit stats, and `git diff --check 5f762a6c..HEAD`; worktree was clean and diff check passed。
|
||||||
|
|
||||||
|
Coder-reported implementation summary:
|
||||||
|
- Added Backend Worker retention APIs for pin/unpin registry rows; Worker list/detail carries `pinned` / `retention_state`; pin/unpin mutates Backend registry only and does not affect Runtime processes。
|
||||||
|
- Added manual Runtime cleanup plan API with separate Worker delete and Workdir cleanup/delete candidates, action kind, reason/blocking reason, linked ids, pinned state, cleanliness/file status, running-link state, and safe reclaim-byte placeholder。
|
||||||
|
- Added manual cleanup execution API requiring expected plan revision/digest; rejects stale plans, pinned Worker/history delete, running-linked Workdir cleanup, and dirty cleanup without explicit discard confirmation; allows safe removed/missing Workdir registry deletion。
|
||||||
|
- Added Browser UI for manual cleanup preview/execution on Runtime Workdirs page and Backend-only pin/unpin controls on Worker page。
|
||||||
|
- Added focused tests for plan generation, stale rejection, pinned protection, running-linked Workdir blocking, dirty confirmation, removed/missing record delete, and raw path redaction。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Route to external Reviewer Pod before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T18:19:11Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
External review result: request_changes
|
||||||
|
|
||||||
|
Blocker:
|
||||||
|
- Dirty Workdir safety is not implemented against real Runtime/filesystem state。
|
||||||
|
- Cleanup plan decides `WorkdirDirtyDiscard` solely from `WorkdirRegistryRecord.cleanliness` (`crates/workspace-server/src/server.rs:1689-1695`)。
|
||||||
|
- Runtime observation sync always writes `cleanliness: "clean"` for observed Workdirs (`server.rs:4179-4199`) and there is no code outside tests that records `"dirty"` (`rg "cleanliness.*dirty|dirty.*cleanliness"` only finds plan/test use)。
|
||||||
|
- Execution then treats such targets as `WorkdirCleanCleanup` and calls Runtime cleanup without dirty confirmation (`server.rs:1851-1852`)。
|
||||||
|
- Runtime cleanup uses forced removal (`crates/worker-runtime/src/working_directory.rs:505-508`)。
|
||||||
|
- This violates acceptance items requiring safe dirty/clean status representation and explicit confirmation before dirty discard。
|
||||||
|
|
||||||
|
Test gap:
|
||||||
|
- The dirty confirmation test seeds a synthetic registry row with `cleanliness = "dirty"` (`server.rs:5436-5449`, `5551-5580`), so it does not catch the real path where Runtime/workdir sync overwrites or fails to detect dirty files。
|
||||||
|
|
||||||
|
Positive evidence:
|
||||||
|
- pin/unpin API/routes and retention projection exist (`server.rs:1544-1584`, `2032-2072`, `4008-4022`)。
|
||||||
|
- plan/execution endpoints include digest/revision stale checks and pinned/running-link blocking (`server.rs:1766-1840`)。
|
||||||
|
- UI preview/execution and pin controls are present without raw path fields。
|
||||||
|
|
||||||
|
Non-blocking note:
|
||||||
|
- `estimated_reclaim_bytes` is present but always `None`; confirm whether “when safe” requires actual byte estimation before approval。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T18:30:52Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up implementation progress report after request_changes:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6CRVBE-manual-cleanup` completed the dirty/unknown Workdir safety fix and committed `361569a69efcba929b59b9c48113add5499d5068 fix: require discard confirmation for unknown workdirs`。
|
||||||
|
- Orchestrator inspected the implementation worktree status, branch log, commit stats, and `git diff --check 5f762a6c..HEAD`; worktree was clean and diff check passed。
|
||||||
|
|
||||||
|
Coder-reported fix summary:
|
||||||
|
- Runtime-observed/materialized Workdirs are no longer marked `clean` without trusted clean evidence。
|
||||||
|
- Observed Workdirs now sync as `cleanliness: "unknown"`。
|
||||||
|
- Cleanup plan downgrades stale `clean` registry rows to `unknown` when real Runtime observation exists。
|
||||||
|
- Present Workdirs with `cleanliness: "unknown"` or `dirty` are classified as discard-confirmation targets, not normal clean cleanup。
|
||||||
|
- Direct Workdir cleanup rejects unknown/dirty Workdirs and requires the cleanup execution API with explicit discard confirmation。
|
||||||
|
- Browser UI labels distinguish verified-clean cleanup from dirty/unknown-state discard。
|
||||||
|
- `estimated_reclaim_bytes` remains intentionally `None` unless a safe estimate is available。
|
||||||
|
|
||||||
|
Regression tests added/updated:
|
||||||
|
- Real observed Workdir without verified clean evidence is classified as `unknown` and `WorkdirDirtyDiscard`。
|
||||||
|
- Normal/direct cleanup is rejected for unknown Workdir state。
|
||||||
|
- Dirty/unknown discard requires explicit confirmation。
|
||||||
|
- Stale `clean` registry state is downgraded when Runtime observation exists。
|
||||||
|
- Existing path-safe cleanup test uses explicit cleanup execution confirmation。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Request focused re-review on the dirty/unknown Workdir blocker and overall acceptance status before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T18:36:11Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Focused re-review result: approve
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Dirty/unknown blocker is fixed。
|
||||||
|
- Runtime/materialized Workdirs now sync as `cleanliness: "unknown"` (`crates/workspace-server/src/server.rs:4208`; pending create also around `1400`)。
|
||||||
|
- Cleanup planning downgrades any live-observed non-dirty row to unknown before action selection (`server.rs:1689-1701`)。
|
||||||
|
- Unknown/dirty present Workdirs become `WorkdirDirtyDiscard` with explicit-confirmation reasons (`server.rs:1721-1725`)。
|
||||||
|
- Direct Workdir DELETE rejects that path before calling Runtime cleanup (`server.rs:1491-1509`)。
|
||||||
|
- Execution still checks expected revision + digest for staleness (`server.rs:1780-1788`), blocks pinned/running-linked Workdirs (`server.rs:1703-1707`, `1834-1839`), and requires `confirm_dirty_discard_target_ids` before forced Runtime cleanup for dirty/unknown targets (`server.rs:1842-1850`)。
|
||||||
|
- Normal clean cleanup is only reachable for `clean` candidates (`server.rs:1698-1699`, `1860-1861`)。
|
||||||
|
|
||||||
|
No observed regressions in reviewed acceptance points:
|
||||||
|
- Pinned Worker deletion remains blocked (`server.rs:1811-1816`)。
|
||||||
|
- Workdir record delete remains separate for removed/missing (`server.rs:1696-1697`, `1869-1879`)。
|
||||||
|
- Plan responses expose ids/status/reasons/links/retention but not raw paths (`server.rs:620-633`)。
|
||||||
|
- UI labels dirty/unknown discard distinctly from verified-clean cleanup with a separate confirm checkbox (`web/workspace/.../workdirs/+page.svelte`)。
|
||||||
|
- `estimated_reclaim_bytes: None` is acceptable here because no safe size source is implemented/exposed; the optional field is present and avoids raw-path traversal/leakage。
|
||||||
|
|
||||||
|
Validation performed by reviewer:
|
||||||
|
- `cargo test -p yoi-workspace-server cleanup --lib` passed。
|
||||||
|
- Targeted regression tests `observed_workdir_without_verified_clean_evidence_requires_discard_confirmation` and `stale_clean_registry_row_is_downgraded_by_real_observation_before_cleanup` passed。
|
||||||
|
- `git diff --check HEAD^..HEAD` passed。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T18:37:23Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Merge / final validation report:
|
||||||
|
|
||||||
|
- Reviewer approved the implementation after the dirty/unknown Workdir safety fix。
|
||||||
|
- Merged branch `work/00001KX6CRVBE-manual-cleanup` into orchestration branch with merge commit `4970a58c merge: manual worker workdir cleanup`。
|
||||||
|
- Ticket record conflicts from child branch were resolved in favor of Orchestrator-authoritative Ticket records; implementation code changes were merged。
|
||||||
|
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Validation log:
|
||||||
|
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/manual-cleanup-final-validation-1783708592.txt`
|
||||||
|
|
||||||
|
Reviewed/merged implementation commits:
|
||||||
|
- `ba44391a feat: add manual worker workdir cleanup`
|
||||||
|
- `361569a6 fix: require discard confirmation for unknown workdirs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T18:37:27Z from: inprogress to: done reason: implementation_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T18:37:40Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T18:37:40Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Manual Worker/Workdir delete and cleanup operations を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Backend Worker registry row の pin/unpin API を追加し、Worker list/detail に `pinned` / `retention_state` を返すようにした。
|
||||||
|
- Runtime ごとの manual cleanup plan API を追加し、Backend Worker/Workdir/link registry と Runtime observation に基づいて Worker delete / Workdir cleanup/delete candidates を生成するようにした。
|
||||||
|
- Plan candidate は action kind、reason/blocking reason、linked Worker/Workdir ids、pinned state、cleanliness/file status、running-link status、safe な reclaim bytes placeholder を含む。
|
||||||
|
- Manual cleanup execution API は expected plan revision/digest を要求し、stale plan を拒否する。
|
||||||
|
- Pinned Worker/history delete、running-linked Workdir cleanup、dirty/unknown Workdir の confirmation なし discard を拒否する。
|
||||||
|
- Removed/missing Workdir registry record delete は安全条件のもとで separate action として扱う。
|
||||||
|
- Runtime-observed/materialized Workdirs は trusted clean evidence なしに `clean` とせず、`unknown` として扱い、normal clean cleanup ではなく explicit discard confirmation path に乗せる。
|
||||||
|
- Browser UI に Runtime Workdirs cleanup preview/execution と Worker pin/unpin controls を追加した。
|
||||||
|
- UI では verified-clean cleanup と dirty/unknown-state discard を区別し、raw Runtime materialized path を表示しない。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- 初回 review は dirty Workdir safety の実データ経路が不十分として `request_changes`。
|
||||||
|
- `361569a6 fix: require discard confirmation for unknown workdirs` で observed Workdir を `unknown` 扱いにし、unknown/dirty cleanup に explicit confirmation を要求するよう修正。
|
||||||
|
- focused re-review は `approve`。
|
||||||
|
- `estimated_reclaim_bytes: None` は safe size source が未実装のため許容と判断された。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `4970a58c merge: manual worker workdir cleanup`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/manual-cleanup-final-validation-1783708592.txt`
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX6WVNPD/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX6WVNPD/artifacts/.gitkeep
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{"id":"orch-plan-20260710-220020-1","ticket_id":"00001KX6WVNPD","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. That prerequisite is currently `inprogress` implementing the WorkerFilesystemAuthority boundary this Ticket relies on. Start only after `00001KX6Y2A9Q` is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:00:20Z"}
|
||||||
|
{"id":"orch-plan-20260710-220024-2","ticket_id":"00001KX6WVNPD","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is otherwise concrete, but typed relation metadata reports blocker `00001KX6Y2A9Q` in state `inprogress`. That prerequisite introduces/removes the Worker filesystem-authority/cwd boundary used by this Ticket. Starting now would duplicate or conflict with that refactor. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:00:24Z"}
|
||||||
|
{"id":"orch-plan-20260710-233758-3","ticket_id":"00001KX6WVNPD","kind":"accepted_plan","accepted_plan":{"summary":"Implement embedded Runtime no-workdir Worker creation policy using the merged `WorkerFilesystemAuthority` boundary: embedded Runtime may create Workers without selected workdir using `WorkerFilesystemAuthority::None`, non-embedded/local-authority paths preserve workdir requirements, no-workdir Workers expose no filesystem/Bash tool surface, and UI/API make workdir optional only for embedded Runtime.","branch":"work/00001KX6WVNPD-embedded-no-workdir-policy","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6WVNPD-embedded-no-workdir-policy","role_plan":"Orchestrator accepts queued Ticket after prerequisite `00001KX6Y2A9Q` was approved, merged, validated, and closed. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Per current user instruction, do not call StopPod during cleanup."},"author":"orchestrator","at":"2026-07-10T23:37:58Z"}
|
||||||
13
.yoi/tickets/00001KX6WVNPD/artifacts/relations.json
Normal file
13
.yoi/tickets/00001KX6WVNPD/artifacts/relations.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"ticket_id": "00001KX6WVNPD",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"target": "00001KX6Y2A9Q",
|
||||||
|
"note": "Embedded no-workdir Worker policy requires WorkerFilesystemAuthority::None so workspace-root/default-scope fallback is not filesystem authority.",
|
||||||
|
"author": "yoi ticket",
|
||||||
|
"at": "2026-07-10T21:24:10Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
.yoi/tickets/00001KX6WVNPD/item.md
Normal file
37
.yoi/tickets/00001KX6WVNPD/item.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
title: 'Embedded no-workdir Worker authority policy'
|
||||||
|
state: 'closed'
|
||||||
|
priority: 'P1'
|
||||||
|
created_at: '2026-07-10T20:52:43Z'
|
||||||
|
updated_at: '2026-07-11T00:13:51Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'workspace-panel'
|
||||||
|
queued_at: '2026-07-10T22:00:07Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`worker/new` では現在 workdir 選択が必須になっているが、embedded Runtime 上に Worker を作る場合は workdir 無しで案内専用の Worker を起動できるようにしたい。
|
||||||
|
|
||||||
|
現状の整理では、前提チケット `00001KX6Y2A9Q` で Worker の filesystem authority が `WorkerFilesystemAuthority::{None, Local}` に分離され、Worker 直下の作業ディレクトリ property は削除される。このチケットではその前提を使い、embedded Runtime の workdir 未指定 spawn を `WorkerFilesystemAuthority::None` として扱う。
|
||||||
|
|
||||||
|
embedded no-workdir Worker では、workspace root や default scope によって意図せず repository filesystem authority を持たないようにする必要がある。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `worker/new` は、選択 Runtime が embedded の場合に限り workdir 未指定で Create できる。
|
||||||
|
- embedded + workdir ありの場合は、Runtime/Execution backend が materialized working directory binding を `WorkerFilesystemAuthority::Local` と Worker status に反映する。
|
||||||
|
- embedded + workdir なしの場合は、`WorkerFilesystemAuthority::None` を指定し、workspace root fallback を filesystem authority として扱わない。
|
||||||
|
- embedded + workdir なしの場合は filesystem tools / Bash / write-capable tools を登録せず、案内・会話中心の read-only/guidance-only Worker として扱う。
|
||||||
|
- FS tools は ambient process directory ではなく、Worker の explicit filesystem authority を基準に登録される。
|
||||||
|
- Bash は filesystem sandbox ではないため、no-workdir embedded では登録しない。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- embedded Runtime 選択時、UI/API 経由で workdir 未指定 Worker を作成できる。
|
||||||
|
- non-embedded または filesystem authority が必要な Runtime/launch path では、既存の workdir 必須制約または明示 authority 要求が維持される。
|
||||||
|
- embedded no-workdir Worker は `WorkerFilesystemAuthority::None` で作成される。
|
||||||
|
- embedded no-workdir Worker の model-visible tool surface に filesystem tools と Bash が現れないことをテストで確認できる。
|
||||||
|
- embedded workdir あり Worker では、working directory status が Worker summary/detail に投影され、tools の working directory base が `WorkerFilesystemAuthority::Local` に一致する。
|
||||||
|
- `Glob` / `Grep` の path 省略や `Bash` の初期ディレクトリ指定が、no-workdir embedded の権限漏れにならない。
|
||||||
|
- 変更後に `cargo test` と `nix build .#yoi` が通る。
|
||||||
28
.yoi/tickets/00001KX6WVNPD/resolution.md
Normal file
28
.yoi/tickets/00001KX6WVNPD/resolution.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
Embedded no-workdir Worker authority policy を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Runtime launch metadata に `working_directory_required` を追加し、embedded Runtime のみ workdir optional として扱うようにした。
|
||||||
|
- Workspace/API Worker create path で、embedded Runtime では no-workdir Worker creation を許可し、non-embedded Runtime では no-workdir creation を typed diagnostic で拒否するようにした。
|
||||||
|
- Embedded no-workdir launch は Runtime へ working directory なしで渡され、merged `WorkerFilesystemAuthority` 境界により `WorkerFilesystemAuthority::None` になる。
|
||||||
|
- Workdir-present embedded Worker は materialized binding を `WorkerFilesystemAuthority::Local` として保持する既存 behavior を維持。
|
||||||
|
- Browser Worker creation UI で embedded Runtime の場合だけ “No working directory — guidance-only Worker; filesystem tools are disabled” を選べるようにした。
|
||||||
|
- No-workdir launch では relative cwd input を隠し、filesystem tools / Bash が disabled であることを明示。
|
||||||
|
- Tests で launch metadata、non-embedded no-workdir rejection、embedded no-workdir request construction、および existing runtime tests による no-workdir tool omission/no cwd fallback を確認。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- Reviewer approved with no blockers。
|
||||||
|
- Evidence included API optionality bounds, non-embedded rejection, authority construction path, no filesystem/Bash tool surface coverage, workdir-present behavior, and Browser labeling。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `2f7b8094 merge: embedded no-workdir worker policy`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `rg 'worker\\.cwd\\(' . || true`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt`
|
||||||
310
.yoi/tickets/00001KX6WVNPD/thread.md
Normal file
310
.yoi/tickets/00001KX6WVNPD/thread.md
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T20:52:43Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: hare at: 2026-07-10T21:24:57Z -->
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
整理結果:
|
||||||
|
|
||||||
|
1. 00001KX6Y2A9Q WorkerFilesystemAuthority
|
||||||
|
- hard dependency なし。
|
||||||
|
- no-workdir を filesystem authority none として型で表現する基盤。
|
||||||
|
|
||||||
|
2. 00001KX6WVNPD Embedded no-workdir Worker authority policy
|
||||||
|
- depends_on: 00001KX6Y2A9Q。
|
||||||
|
- UI/API の workdir 任意化と embedded no-workdir tool surface 制御を実装する。
|
||||||
|
|
||||||
|
3. 00001KX6Y6ZEA WorkspaceBackend / workspace_id 分離
|
||||||
|
- depends_on: 00001KX6Y2A9Q。
|
||||||
|
- workspace_root path 依存を workspace_id + Runtime-injected WorkspaceClient へ移行する。
|
||||||
|
- embedded no-workdir MVP をブロックしない。
|
||||||
|
|
||||||
|
Hard dependencies は WorkerFilesystemAuthority を共通前提に限定する。WorkspaceBackend 分離は embedded no-workdir policy の MVP には related だが depends_on にはしない。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T21:54:14Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
前提チケット `00001KX6Y2A9Q` で Worker 直下の作業ディレクトリ property は削除されるため、このチケットはその存在を前提にしない。embedded workdir ありは `WorkerFilesystemAuthority::Local`、workdir なしは `WorkerFilesystemAuthority::None` として扱い、tool registration もその authority から導出する。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T21:59:20Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T21:59:20Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: workspace-panel at: 2026-07-10T22:00:07Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T22:00:33Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: blocked_by_dependency
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Dashboard queue authorization was inspected, but this Ticket has an explicit typed `depends_on` relation to `00001KX6Y2A9Q`。
|
||||||
|
- `00001KX6Y2A9Q` is currently `inprogress` and is implementing the Worker filesystem-authority / no-workdir / cwd-removal boundary this Ticket depends on。
|
||||||
|
- Starting this Ticket before that boundary is reviewed/merged would duplicate or conflict with constructor/restore/spawn/tool-registration changes。
|
||||||
|
- Therefore this routing pass leaves the Ticket `queued` and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / recent thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX6WVNPD)`: includes dependency on `00001KX6Y2A9Q`。
|
||||||
|
- `TicketList(inprogress)`: `00001KX6Y2A9Q` is the active inprogress Ticket。
|
||||||
|
- Orchestrator worktree status: clean。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Re-route this Ticket after `00001KX6Y2A9Q` is approved, merged, validated, and closed。
|
||||||
|
- Escalate only if a human explicitly requests combined implementation in the same branch/worktree。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T23:38:28Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- 前提 Ticket `00001KX6Y2A9Q` は approved / merged / final-validated / closed 済みで、`WorkerFilesystemAuthority::{None, Local}` と no-workdir core filesystem/Bash tool omission boundary が利用可能になった。
|
||||||
|
- この Ticket は embedded Runtime の workdir 未指定 Worker 作成 policy と UI/API 制約に集中しており、目的・binding decision・受け入れ条件が具体的である。
|
||||||
|
- `TicketRelationQuery(00001KX6WVNPD)` の blocker view は空で、残る outgoing `depends_on 00001KX6Y2A9Q` は closed prerequisite になっている。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX6WVNPD)` の既存 waiting note は prerequisite inprogress を理由にしたものだったため、現在は解消済み。
|
||||||
|
- Orchestrator worktree は clean。既存 child implementation branch/worktree は見当たらない。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / relations / orchestration plan。
|
||||||
|
- Related closed Ticket `00001KX6Y2A9Q` resolution/review/validation evidence。
|
||||||
|
- `TicketList(inprogress)`: 0 件。
|
||||||
|
- `git status --short --branch` and `git worktree list` in `/home/hare/Projects/yoi/.worktree/orchestration`。
|
||||||
|
- Current queue also contains related `00001KX6Y6ZEA`, but its Worker workspace-backend refactor touches overlapping Worker context/constructor surfaces; it will be held separately with a conflict/migration-boundary reason rather than started in parallel。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
|
||||||
|
Intent:
|
||||||
|
- Embedded Runtime 選択時に、workdir 未指定の guidance/conversation-oriented Worker を作成できるようにする。
|
||||||
|
- Embedded no-workdir Worker は `WorkerFilesystemAuthority::None` として作られ、repository filesystem authority / cwd fallback / filesystem tools / Bash を持たない。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- no-workdir authority は merged `WorkerFilesystemAuthority::None` を使う。
|
||||||
|
- workspace root / process cwd / runtime cwd / manifest override / default scope を filesystem authority fallback にしない。
|
||||||
|
- filesystem tools (`Read`, `Write`, `Edit`, `Glob`, `Grep`) と `Bash` は no-workdir embedded Worker では construct/register/model-visible にしない。
|
||||||
|
- Bash は filesystem sandbox ではないため、no-workdir embedded では登録しない。
|
||||||
|
- non-embedded または local/filesystem-authority-required launch path では、既存の workdir 必須制約または明示 authority requirement を維持する。
|
||||||
|
- Worker 直下の `cwd` property / `worker.cwd()` 前提に戻さない。
|
||||||
|
|
||||||
|
Requirements / acceptance criteria:
|
||||||
|
- UI/API 経由で embedded Runtime の workdir 未指定 Worker create ができる。
|
||||||
|
- embedded + workdir ありは materialized binding を `WorkerFilesystemAuthority::Local` と Worker summary/detail に反映する。
|
||||||
|
- embedded + workdir なしは `WorkerFilesystemAuthority::None` になり、model-visible tool surface に fs tools / Bash が出ないことをテストで確認できる。
|
||||||
|
- `Glob` / `Grep` path 省略や `Bash` initial directory が no-workdir embedded の権限漏れにならない。
|
||||||
|
- Browser/UI では workdir optional が embedded Runtime に限られ、non-embedded/local authority path の制約を緩めない。
|
||||||
|
|
||||||
|
Implementation latitude:
|
||||||
|
- API DTO / UI form model / runtime spawn request shape の具体的な整理は coder が既存 pattern に沿って選んでよい。
|
||||||
|
- workdir 未指定状態の UI 表示文言・validation placement は、binding invariant を満たす範囲で local tactic としてよい。
|
||||||
|
- tests の配置は worker-runtime / workspace-server / web の既存近接 test pattern に合わせてよい。
|
||||||
|
|
||||||
|
Escalate if:
|
||||||
|
- embedded no-workdir Worker に workspace API access を付けるために `WorkspaceBackend` / `WorkspaceClient` の先行導入が必須になる場合。
|
||||||
|
- no-workdir Worker へ fs/Bash tool の error-only stub や empty-scope fallback を登録したくなる場合。
|
||||||
|
- non-embedded Worker の workdir requirement を緩める必要が出る場合。
|
||||||
|
- `WorkerFilesystemAuthority` の既存 invariant を変更する必要が出る場合。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `git diff --check`
|
||||||
|
- focused grep/check that no no-workdir path reintroduces `worker.cwd()` or local cwd fallback。
|
||||||
|
- `cargo test -p worker --lib --tests` if worker crate is touched。
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test` if web/API/types are touched。
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Current code map / likely touch points:
|
||||||
|
- `crates/worker/src/worker.rs`, `crates/worker/src/controller.rs` for existing authority boundary verification only if needed。
|
||||||
|
- `crates/worker-runtime/src/worker_backend.rs` / working-directory binding and embedded spawn paths。
|
||||||
|
- `crates/workspace-server/src/server.rs` / API DTOs for Worker create/runtime options。
|
||||||
|
- `web/workspace` worker creation UI/types if workdir optionality is exposed there。
|
||||||
|
|
||||||
|
Critical risks / reviewer focus:
|
||||||
|
- Hidden filesystem authority through workspace root / process cwd / runtime cwd fallback。
|
||||||
|
- no-workdir Worker showing fs tools/Bash in model-visible schema。
|
||||||
|
- empty-scope/error-only fs tool registration rather than no registration。
|
||||||
|
- UI/API accidentally allowing non-embedded no-workdir Worker where local filesystem authority is still required。
|
||||||
|
- regression of embedded workdir-present Worker status/tool cwd behavior。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T23:38:34Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Prerequisite `00001KX6Y2A9Q` is now closed and merged. This Ticket is accepted for implementation before creating a worktree or spawning role Pods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-11T00:00:55Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6WVNPD-no-workdir-policy` completed an implementation pass in `/home/hare/Projects/yoi/.worktree/00001KX6WVNPD-embedded-no-workdir-policy` on branch `work/00001KX6WVNPD-embedded-no-workdir-policy`。
|
||||||
|
- Implementation commit: `10267869ae0dfe02e186dc69fec14151926f5009 feat: enforce embedded no-workdir worker policy`。
|
||||||
|
- Files touched:
|
||||||
|
- `crates/workspace-server/src/server.rs`
|
||||||
|
- `web/workspace/src/lib/workspace-sidebar/types.ts`
|
||||||
|
- `web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts`
|
||||||
|
- `web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte`
|
||||||
|
- Orchestrator inspected worktree status, branch log, commit stats, `git diff --check 83c9d884..HEAD`, and `rg 'worker\\.cwd\\(\\)' .`; worktree was clean, diff check passed, and no `worker.cwd()` matches were found。
|
||||||
|
|
||||||
|
Implementation summary:
|
||||||
|
- Added Browser/API launch metadata for whether a runtime requires working directory selection。
|
||||||
|
- Embedded Runtime can be marked as workdir-optional; non-embedded Runtime remains workdir-required。
|
||||||
|
- Server-side creation path rejects no-workdir Worker creation unless the target runtime is embedded, using a typed diagnostic。
|
||||||
|
- Browser Worker creation UI offers “no workdir / embedded conversation only” only when the selected runtime allows it。
|
||||||
|
- Relative cwd input is hidden for no-workdir launch and UI text explains no filesystem tools or Bash are available without workdir。
|
||||||
|
- Tests were added/updated for launch metadata, non-embedded no-workdir rejection, and web request construction for embedded no-workdir launch。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Route to external Reviewer Pod before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-11T00:12:24Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
External review result: approve
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- UI/API optionality is correctly bounded: launch options expose `working_directory_required` and set it false only for embedded runtime (`crates/workspace-server/src/server.rs:749`, `:3823`), while browser request construction omits `working_directory` for embedded no-workdir mode and tests cover that (`web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts:80`)。
|
||||||
|
- Non-embedded no-workdir is rejected in workspace/API launch paths (`server.rs:2639`, `:2839`) with tests covering the 400 behavior (`server.rs:6383`, `:6554`)。
|
||||||
|
- Authority boundary is preserved: absent materialized workdir reaches runtime as no working directory (`server.rs:2652`) and worker-runtime maps that to `WorkerFilesystemAuthority::None`; workdir-present maps to `Local(root, cwd)` (`crates/worker-runtime/src/worker_backend.rs:301`)。
|
||||||
|
- Tool surface/no fallback is covered by existing runtime tests asserting no cwd observation and omission of `Read`/`Write`/`Edit`/`Glob`/`Grep`/`Bash` for no-workdir workers (`worker_backend.rs:1151`, `:1216`)。
|
||||||
|
- Workdir-present behavior remains covered by materialized binding assertions (`worker_backend.rs:1151`) and server worker creation still links/summarizes workdir-present workers。
|
||||||
|
- Browser labeling is explicit: embedded mode offers “No working directory — guidance-only Worker; filesystem tools are disabled” and non-embedded paths require selection (`+page.svelte:153`, `:289`)。
|
||||||
|
|
||||||
|
Validation performed by reviewer:
|
||||||
|
- `cargo test -p yoi-workspace-server no_workdir -- --nocapture`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server --lib -- --nocapture`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- Final `git status --short` was clean。
|
||||||
|
|
||||||
|
Blockers: none。
|
||||||
|
Non-blocking notes: none。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-11T00:13:31Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Merge / final validation report:
|
||||||
|
|
||||||
|
- Reviewer approved the implementation with no blockers。
|
||||||
|
- Merged branch `work/00001KX6WVNPD-embedded-no-workdir-policy` into orchestration branch with merge commit `2f7b8094 merge: embedded no-workdir worker policy`。
|
||||||
|
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
- `rg 'worker\\.cwd\\(' . || true`: no matches。
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Validation log:
|
||||||
|
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt`
|
||||||
|
|
||||||
|
Reviewed/merged implementation commit:
|
||||||
|
- `10267869 feat: enforce embedded no-workdir worker policy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-11T00:13:37Z from: inprogress to: done reason: implementation_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T00:13:51Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T00:13:51Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Embedded no-workdir Worker authority policy を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Runtime launch metadata に `working_directory_required` を追加し、embedded Runtime のみ workdir optional として扱うようにした。
|
||||||
|
- Workspace/API Worker create path で、embedded Runtime では no-workdir Worker creation を許可し、non-embedded Runtime では no-workdir creation を typed diagnostic で拒否するようにした。
|
||||||
|
- Embedded no-workdir launch は Runtime へ working directory なしで渡され、merged `WorkerFilesystemAuthority` 境界により `WorkerFilesystemAuthority::None` になる。
|
||||||
|
- Workdir-present embedded Worker は materialized binding を `WorkerFilesystemAuthority::Local` として保持する既存 behavior を維持。
|
||||||
|
- Browser Worker creation UI で embedded Runtime の場合だけ “No working directory — guidance-only Worker; filesystem tools are disabled” を選べるようにした。
|
||||||
|
- No-workdir launch では relative cwd input を隠し、filesystem tools / Bash が disabled であることを明示。
|
||||||
|
- Tests で launch metadata、non-embedded no-workdir rejection、embedded no-workdir request construction、および existing runtime tests による no-workdir tool omission/no cwd fallback を確認。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- Reviewer approved with no blockers。
|
||||||
|
- Evidence included API optionality bounds, non-embedded rejection, authority construction path, no filesystem/Bash tool surface coverage, workdir-present behavior, and Browser labeling。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `2f7b8094 merge: embedded no-workdir worker policy`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `rg 'worker\\.cwd\\(' . || true`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt`
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX6Y2A9Q/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX6Y2A9Q/artifacts/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"id":"orch-plan-20260710-215118-1","ticket_id":"00001KX6Y2A9Q","kind":"accepted_plan","accepted_plan":{"summary":"Introduce explicit `WorkerFilesystemAuthority` with `None` and `Local(LocalWorkingDirectory)` variants, remove Worker-level `cwd` field/accessor, gate core filesystem tools/Bash on local filesystem authority, and update constructor/restore/embedded/spawn paths plus tests so no-workdir Workers have no filesystem tool surface or cwd fallback.","branch":"work/00001KX6Y2A9Q-worker-filesystem-authority","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6Y2A9Q-worker-filesystem-authority","role_plan":"Orchestrator accepts queued Ticket after inspecting current Ticket, relations, orchestration plan, inprogress queue, and clean orchestration worktree. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/cleanup authority."},"author":"orchestrator","at":"2026-07-10T21:51:18Z"}
|
||||||
13
.yoi/tickets/00001KX6Y2A9Q/artifacts/relations.json
Normal file
13
.yoi/tickets/00001KX6Y2A9Q/artifacts/relations.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"ticket_id": "00001KX6Y2A9Q",
|
||||||
|
"kind": "related",
|
||||||
|
"target": "00001KX6WVNPD",
|
||||||
|
"note": "WorkerFilesystemAuthority provides the implementation boundary for embedded no-workdir Worker authority policy.",
|
||||||
|
"author": "yoi ticket",
|
||||||
|
"at": "2026-07-10T21:14:19Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
60
.yoi/tickets/00001KX6Y2A9Q/item.md
Normal file
60
.yoi/tickets/00001KX6Y2A9Q/item.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
---
|
||||||
|
title: 'Implement WorkerFilesystemAuthority for no-workdir Workers'
|
||||||
|
state: 'closed'
|
||||||
|
priority: 'P1'
|
||||||
|
created_at: '2026-07-10T21:13:49Z'
|
||||||
|
updated_at: '2026-07-10T22:45:01Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'workspace-panel'
|
||||||
|
queued_at: '2026-07-10T21:51:00Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Worker 実装は現在 `workspace_root: PathBuf` と `cwd: PathBuf` を必須で持ち、tools 登録も `worker.cwd()` と `worker.scope()` を基準にしている。workdir あり Worker では `cwd` が実質 working directory として機能しており問題ないが、embedded no-workdir Worker では「filesystem authority が存在しない」状態を型として表現できない。
|
||||||
|
|
||||||
|
no-workdir Worker は、内部プロセスの実 cwd や fallback 元が何であっても、Worker authority としては filesystem に一切アクセスできないべきである。仮想 cwd を与えるだけでは、`Glob` / `Grep` の default base、`Bash.current_dir`、scope default、Ticket/memory/workflow path 解決などから再び filesystem 接点が発生し得る。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Worker に filesystem authority の有無を表す明示的な型を導入する。
|
||||||
|
- Worker の `cwd: PathBuf` property と `worker.cwd()` accessor を削除し、working directory は `WorkerFilesystemAuthority::Local` の中だけで表現する。
|
||||||
|
- workdir あり Worker は local working directory として `root` と `cwd` を持ち、既存の tools default base / Bash cwd / fs view はこの値を使う。
|
||||||
|
- no-workdir Worker は filesystem authority を `None` として表現し、Worker authority としての cwd fallback を持たない。
|
||||||
|
- filesystem authority が無い Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` を登録しない。
|
||||||
|
- 既存の `worker.cwd()` 参照箇所をすべて分類し、filesystem authority が必要な箇所は `WorkerFilesystemAuthority::Local` 経由に置き換え、workspace 情報が必要な箇所は `workspace_root`/cwd 依存から切り離す方向で明示的に扱う。
|
||||||
|
- `workspace_root` はこのチケットでは全削除しなくてよいが、filesystem authority として使わない境界を明確にする。
|
||||||
|
|
||||||
|
## 実装メモ
|
||||||
|
|
||||||
|
想定する型の方向性:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enum WorkerFilesystemAuthority {
|
||||||
|
None,
|
||||||
|
Local(LocalWorkingDirectory),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalWorkingDirectory {
|
||||||
|
root: PathBuf,
|
||||||
|
cwd: PathBuf,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker 構築時に `WorkerFilesystemAuthority` を必須入力として受け取り、既存の `cwd: PathBuf` / `worker.cwd()` 経路を削除する。no-workdir embedded Worker は `WorkerFilesystemAuthority::None` を渡す。通常 Worker / workdir あり embedded Worker / spawned child Worker は、既存の working directory 解決結果から `LocalWorkingDirectory { root, cwd }` を作って渡す。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- Worker が filesystem authority 有無を型で保持できる。
|
||||||
|
- Worker struct から `cwd: PathBuf` field が削除され、`worker.cwd()` accessor も存在しない。
|
||||||
|
- Worker の constructor / restore / embedded runtime spawn / child spawn 経路は `WorkerFilesystemAuthority` を明示的に受け渡す。
|
||||||
|
- workdir あり Worker では、tools の default cwd が `LocalWorkingDirectory.cwd` に一致する。
|
||||||
|
- workdir あり Worker では、authority root が `LocalWorkingDirectory.root` として保持され、cwd と root の意味が分かれている。
|
||||||
|
- no-workdir Worker では core filesystem tools と Bash が model-visible tool surface に現れないことをテストで確認できる。
|
||||||
|
- no-workdir Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` が実行経路上も構築されず、空 scope や実行時エラー頼りの制御になっていない。
|
||||||
|
- no-workdir Worker 作成時に workspace root / process cwd / runtime cwd fallback が filesystem authority として使われない。
|
||||||
|
- embedded no-workdir Worker の spawn 経路から `WorkerFilesystemAuthority::None` を指定できる。
|
||||||
|
- 既存の `worker.cwd()` 利用箇所が残っていないことを grep または同等のテストで確認できる。
|
||||||
|
- Ticket / memory / workflow / child spawn など、従来 cwd に依存していた箇所は `WorkerFilesystemAuthority::Local` 必須箇所と workspace context 箇所に分類され、no-workdir で local filesystem に触れない。
|
||||||
|
- 既存の通常 Worker / workdir あり embedded Worker / spawned child Worker の動作が回帰しない。
|
||||||
|
- `cargo test` と `nix build .#yoi` が通る。
|
||||||
32
.yoi/tickets/00001KX6Y2A9Q/resolution.md
Normal file
32
.yoi/tickets/00001KX6Y2A9Q/resolution.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
Worker filesystem authority の明示境界を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` を追加。
|
||||||
|
- `LocalWorkingDirectory` は filesystem authority `root` と default `cwd` を分離して保持する。
|
||||||
|
- Worker-level `cwd: PathBuf` field と `worker.cwd()` accessor を削除。
|
||||||
|
- core filesystem/Bash tool registration は local filesystem authority がある場合だけ行うようにした。
|
||||||
|
- No-workdir Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` を construct/register/model-visible にしない。
|
||||||
|
- Embedded no-workdir Worker spawn は Runtime/process cwd fallback ではなく `WorkerFilesystemAuthority::None` を明示的に渡す。
|
||||||
|
- Ticket / worker-spawn / peer-discovery など cwd 依存箇所は local filesystem authority 必須に整理し、workspace root fallback を authority として使わないようにした。
|
||||||
|
- tests で no-workdir tool omission、no cwd fallback、local/materialized Worker の filesystem/Bash behavior を確認。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- Reviewer approved with no blockers。
|
||||||
|
- Evidence included explicit authority type, no Worker cwd field/accessor, filesystem/Bash registration gate, embedded Runtime authority construction, and tests。
|
||||||
|
- Non-blocking note: workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this is outside the focused core filesystem/Bash authority gate and may become follow-up if the invariant expands to “no local file reads of any kind”。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `c29e9135 merge: worker filesystem authority`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `rg "worker\\.cwd\\(" crates || true`
|
||||||
|
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt`
|
||||||
258
.yoi/tickets/00001KX6Y2A9Q/thread.md
Normal file
258
.yoi/tickets/00001KX6Y2A9Q/thread.md
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T21:13:49Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: hare at: 2026-07-10T21:24:57Z -->
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
整理結果:
|
||||||
|
|
||||||
|
1. 00001KX6Y2A9Q WorkerFilesystemAuthority
|
||||||
|
- hard dependency なし。
|
||||||
|
- no-workdir を filesystem authority none として型で表現する基盤。
|
||||||
|
|
||||||
|
2. 00001KX6WVNPD Embedded no-workdir Worker authority policy
|
||||||
|
- depends_on: 00001KX6Y2A9Q。
|
||||||
|
- UI/API の workdir 任意化と embedded no-workdir tool surface 制御を実装する。
|
||||||
|
|
||||||
|
3. 00001KX6Y6ZEA WorkspaceBackend / workspace_id 分離
|
||||||
|
- depends_on: 00001KX6Y2A9Q。
|
||||||
|
- workspace_root path 依存を workspace_id + Runtime-injected WorkspaceClient へ移行する。
|
||||||
|
- embedded no-workdir MVP をブロックしない。
|
||||||
|
|
||||||
|
Hard dependencies は WorkerFilesystemAuthority を共通前提に限定する。WorkspaceBackend 分離は embedded no-workdir policy の MVP には related だが depends_on にはしない。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T21:46:21Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Clarification: `process_cwd` is not part of this design. The Worker-level `cwd: PathBuf` property and `worker.cwd()` accessor should be removed rather than kept as a legacy authority surface. Working directory exists only under `WorkerFilesystemAuthority::Local`; no-workdir uses `WorkerFilesystemAuthority::None`. Tool registration must be derived from that authority, not manifest override or cwd fallback.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T21:49:37Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T21:49:37Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: workspace-panel at: 2026-07-10T21:51:00Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T21:51:29Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: accept for implementation
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket `00001KX6Y2A9Q` is `queued` and has a concrete implementation scope。
|
||||||
|
- `TicketRelationQuery(00001KX6Y2A9Q)` shows incoming dependents (`00001KX6WVNPD`, `00001KX6Y6ZEA`) but no outgoing unresolved blockers for this Ticket。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX6Y2A9Q)` had no prior plan; accepted plan was recorded in this routing pass。
|
||||||
|
- `TicketList(inprogress)` shows 0 inprogress Tickets。
|
||||||
|
- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` is clean。
|
||||||
|
|
||||||
|
Accepted plan:
|
||||||
|
- Create dedicated implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX6Y2A9Q-worker-filesystem-authority` from current orchestration branch。
|
||||||
|
- Use branch `work/00001KX6Y2A9Q-worker-filesystem-authority`。
|
||||||
|
- Route implementation to sibling Coder Pod, then external review to sibling Reviewer Pod。
|
||||||
|
- Orchestrator retains merge, final validation, Ticket close, and child worktree/branch cleanup authority。
|
||||||
|
|
||||||
|
Implementation focus:
|
||||||
|
- Add explicit `WorkerFilesystemAuthority` with no-workdir `None` and local `Local(LocalWorkingDirectory)` representation。
|
||||||
|
- Remove Worker-level `cwd: PathBuf` field and `worker.cwd()` accessor。
|
||||||
|
- Gate core filesystem tools and Bash on `WorkerFilesystemAuthority::Local`; no-workdir Workers must not register those tools。
|
||||||
|
- Update constructor / restore / embedded / child-spawn paths to pass filesystem authority explicitly。
|
||||||
|
- Separate workspace context from filesystem authority; do not use workspace root / process cwd / runtime cwd fallback as no-workdir authority。
|
||||||
|
- Add tests for no-workdir model-visible tool surface and execution construction behavior, plus grep/equivalent assurance that `worker.cwd()` usages are gone。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T21:51:33Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Dashboard queue authorization was inspected, no blockers were found, and an accepted plan was recorded. Moving queued Ticket to inprogress before creating implementation worktree or spawning Pods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T22:31:16Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6Y2A9Q-worker-fs-authority` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6Y2A9Q-worker-filesystem-authority` on branch `work/00001KX6Y2A9Q-worker-filesystem-authority`。
|
||||||
|
- Implementation commit: `b50b94612a1185dc5bdc935d83d63f9d6ace826b feat: add explicit worker filesystem authority`。
|
||||||
|
- Orchestrator inspected the worktree status, branch log, commit stats, `git diff --check db3a7165..HEAD`, and grep checks for `worker.cwd()` / Worker `fn cwd`; worktree was clean, diff check passed, and no matches were found。
|
||||||
|
|
||||||
|
Coder-reported implementation summary:
|
||||||
|
- Added explicit `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` boundary。
|
||||||
|
- Removed Worker-level `cwd: PathBuf` and `worker.cwd()` accessor。
|
||||||
|
- Local Workers carry authority `root` and `cwd` separately。
|
||||||
|
- No-workdir embedded Worker spawn passes `WorkerFilesystemAuthority::None` explicitly instead of falling back to runtime/process cwd。
|
||||||
|
- Controller tool registration branches on local filesystem authority:
|
||||||
|
- Local Workers register `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Bash` and get fs view/completions。
|
||||||
|
- No-workdir Workers do not construct/register filesystem or Bash tools。
|
||||||
|
- Ticket / worker-spawn / peer-discovery cwd-dependent paths now require local filesystem authority instead of using workspace root as fallback。
|
||||||
|
- Tests added/updated for no-workdir tool omission, no cwd fallback, and local/materialized Worker filesystem/Bash behavior。
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo run -p yoi -- ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- `rg "worker\\.cwd\\(" crates || true`: no matches。
|
||||||
|
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`: no Worker cwd accessor remains。
|
||||||
|
|
||||||
|
Not run by Coder:
|
||||||
|
- `cd web/workspace && deno task check && deno task test` because web/API/types were not touched。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Route to external Reviewer Pod before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-10T22:40:35Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
External review result: approve
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` and separate `root`/`cwd` are present in `crates/worker/src/worker.rs`; `Worker` now stores `filesystem_authority` plus separate `workspace_root`, with no `cwd: PathBuf` Worker field。
|
||||||
|
- Core filesystem/Bash registration is gated on `worker.local_working_directory()` in `crates/worker/src/controller.rs:619-636`; no-local Workers return `(None, None)` and do not construct/register `Read`/`Write`/`Edit`/`Glob`/`Grep`/`Bash`。
|
||||||
|
- Ticket and worker-spawn surfaces require local authority rather than falling back to `workspace_root` (`controller.rs:647-667`, `719-733`)。
|
||||||
|
- Embedded runtime creates `WorkerFilesystemAuthority::Local(binding.root, binding.cwd)` only when a working directory binding exists, otherwise `WorkerFilesystemAuthority::None` (`crates/worker-runtime/src/worker_backend.rs:297-310`)。
|
||||||
|
- Tests cover no-workdir omission and no cwd fallback via empty observed materialized cwds plus forbidden core tool names (`worker_backend.rs:1133-1201`), and materialized local Workers exposing core tools with materialized cwd (`worker_backend.rs:1203-1260`)。
|
||||||
|
- Grep check found no `worker.cwd()` / Worker `fn cwd`; the only remaining `pub fn cwd` hit is `WorkingDirectory` binding API, not `Worker`。
|
||||||
|
|
||||||
|
Blockers: none。
|
||||||
|
|
||||||
|
Validation performed by reviewer:
|
||||||
|
- Read-only ticket/diff/source/test inspection and grep checks。
|
||||||
|
- Reviewer did not rerun cargo/nix tests; coder-reported validation covers the requested test/build set。
|
||||||
|
|
||||||
|
Non-blocking note:
|
||||||
|
- Workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this was treated as outside this Ticket’s focused core filesystem/Bash authority gate, but may need follow-up if the intended invariant becomes “no local file reads of any kind” for no-workdir Workers。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-10T22:44:39Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Merge / final validation report:
|
||||||
|
|
||||||
|
- Reviewer approved the implementation with no blockers。
|
||||||
|
- Merged branch `work/00001KX6Y2A9Q-worker-filesystem-authority` into orchestration branch with merge commit `c29e9135 merge: worker filesystem authority`。
|
||||||
|
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
- `rg "worker\\.cwd\\(" crates || true`: no matches。
|
||||||
|
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`: no Worker cwd accessor matches。
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Validation log:
|
||||||
|
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt`
|
||||||
|
|
||||||
|
Reviewed/merged implementation commit:
|
||||||
|
- `b50b9461 feat: add explicit worker filesystem authority`
|
||||||
|
|
||||||
|
Non-blocking reviewer note:
|
||||||
|
- Workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this was treated as outside this Ticket’s focused core filesystem/Bash authority gate, but may be follow-up material if the intended invariant becomes “no local file reads of any kind” for no-workdir Workers。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-10T22:44:45Z from: inprogress to: done reason: implementation_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T22:45:01Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T22:45:01Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Worker filesystem authority の明示境界を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` を追加。
|
||||||
|
- `LocalWorkingDirectory` は filesystem authority `root` と default `cwd` を分離して保持する。
|
||||||
|
- Worker-level `cwd: PathBuf` field と `worker.cwd()` accessor を削除。
|
||||||
|
- core filesystem/Bash tool registration は local filesystem authority がある場合だけ行うようにした。
|
||||||
|
- No-workdir Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` を construct/register/model-visible にしない。
|
||||||
|
- Embedded no-workdir Worker spawn は Runtime/process cwd fallback ではなく `WorkerFilesystemAuthority::None` を明示的に渡す。
|
||||||
|
- Ticket / worker-spawn / peer-discovery など cwd 依存箇所は local filesystem authority 必須に整理し、workspace root fallback を authority として使わないようにした。
|
||||||
|
- tests で no-workdir tool omission、no cwd fallback、local/materialized Worker の filesystem/Bash behavior を確認。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- Reviewer approved with no blockers。
|
||||||
|
- Evidence included explicit authority type, no Worker cwd field/accessor, filesystem/Bash registration gate, embedded Runtime authority construction, and tests。
|
||||||
|
- Non-blocking note: workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this is outside the focused core filesystem/Bash authority gate and may become follow-up if the invariant expands to “no local file reads of any kind”。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `c29e9135 merge: worker filesystem authority`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `rg "worker\\.cwd\\(" crates || true`
|
||||||
|
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt`
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX6Y6ZEA/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX6Y6ZEA/artifacts/.gitkeep
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{"id":"orch-plan-20260710-220343-1","ticket_id":"00001KX6Y6ZEA","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. The prerequisite WorkerFilesystemAuthority refactor is currently `inprogress` and establishes the filesystem-authority/workspace-boundary this Ticket builds on. Start only after the prerequisite is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:03:43Z"}
|
||||||
|
{"id":"orch-plan-20260710-220349-2","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is concrete but blocked by typed `depends_on` relation to `00001KX6Y2A9Q`, which is currently inprogress. Starting now would conflict with the Worker struct/context/constructor refactor and filesystem-authority boundary. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:03:49Z"}
|
||||||
|
{"id":"orch-plan-20260710-233841-3","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Queue review resumed after prerequisite `00001KX6Y2A9Q` closed. `00001KX6WVNPD` was accepted first because it is the smaller embedded no-workdir policy MVP and explicitly does not require WorkspaceBackend migration. This Ticket remains queued due migration-boundary/conflict risk: it removes Worker `workspace_root` and introduces WorkspaceBackend/WorkspaceClient surfaces in the same Worker context/constructor/runtime code that `00001KX6WVNPD` is now changing. Re-route after `00001KX6WVNPD` is reviewed/merged/closed, unless a human explicitly requests a combined branch.","author":"orchestrator","at":"2026-07-10T23:38:41Z"}
|
||||||
|
{"id":"orch-plan-20260711-001433-4","ticket_id":"00001KX6Y6ZEA","kind":"accepted_plan","accepted_plan":{"summary":"Replace Worker `workspace_root: PathBuf` identity with path-free workspace identity and Runtime/host-owned WorkspaceBackend/WorkspaceClient boundary. Preserve filesystem authority exclusively under `WorkerFilesystemAuthority::Local`, allow workspace_id/client without local filesystem authority, and migrate workspace-aware Worker references away from local workspace paths where in scope.","branch":"work/00001KX6Y6ZEA-workspace-backend","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6Y6ZEA-workspace-backend","role_plan":"Orchestrator accepts queued Ticket after prerequisites `00001KX6Y2A9Q` and related `00001KX6WVNPD` were approved, merged, validated, and closed. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Per user instruction, do not call StopPod during cleanup."},"author":"orchestrator","at":"2026-07-11T00:14:33Z"}
|
||||||
29
.yoi/tickets/00001KX6Y6ZEA/artifacts/relations.json
Normal file
29
.yoi/tickets/00001KX6Y6ZEA/artifacts/relations.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"ticket_id": "00001KX6Y6ZEA",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"target": "00001KX6Y2A9Q",
|
||||||
|
"note": "Workspace identity/backend refactor should build on the explicit filesystem-authority boundary.",
|
||||||
|
"author": "yoi ticket",
|
||||||
|
"at": "2026-07-10T21:24:10Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "00001KX6Y6ZEA",
|
||||||
|
"kind": "related",
|
||||||
|
"target": "00001KX6WVNPD",
|
||||||
|
"note": "Embedded no-workdir Workers need workspace API access without local filesystem authority.",
|
||||||
|
"author": "yoi ticket",
|
||||||
|
"at": "2026-07-10T21:16:50Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "00001KX6Y6ZEA",
|
||||||
|
"kind": "related",
|
||||||
|
"target": "00001KX6Y2A9Q",
|
||||||
|
"note": "WorkspaceBackend separates workspace identity/API access from WorkerFilesystemAuthority local filesystem authority.",
|
||||||
|
"author": "yoi ticket",
|
||||||
|
"at": "2026-07-10T21:16:50Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
85
.yoi/tickets/00001KX6Y6ZEA/item.md
Normal file
85
.yoi/tickets/00001KX6Y6ZEA/item.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
title: 'Replace Worker workspace_root path with WorkspaceBackend'
|
||||||
|
state: 'closed'
|
||||||
|
priority: 'P1'
|
||||||
|
created_at: '2026-07-10T21:16:22Z'
|
||||||
|
updated_at: '2026-07-11T01:09:59Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'workspace-panel'
|
||||||
|
queued_at: '2026-07-10T22:03:27Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Worker 実装は現在 `workspace_root: PathBuf` を持ち、project records / memory / workflow / Ticket config / Profile context / child spawn inheritance / default scope など複数の意味に使っている。しかし workspace identity と local filesystem authority が path で混ざっており、embedded no-workdir Worker のように「workspace 情報には API 経由でアクセスできるが local filesystem には触れない」状態を自然に表現できない。
|
||||||
|
|
||||||
|
workspace は local path ではなく Backend/API 側の identity として扱うべきである。Worker が durable/context identity として持つのは `Option<WorkspaceId>` に留め、`WorkspaceBackendRef` や backend adapter/client の materialization は Runtime/host 側が持つ。Worker は Runtime から注入された narrow `WorkspaceClient` / handle を通じて workspace-aware API に request する。local filesystem authority は別チケットの `WorkerFilesystemAuthority` に分離する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Worker の workspace identity/context を `workspace_root: PathBuf` から `workspace_id: Option<WorkspaceId>` へ移行する。
|
||||||
|
- Worker struct から `workspace_root: PathBuf` field を削除し、Worker の workspace identity として local path を保持しない。
|
||||||
|
- `WorkspaceBackendRef` は Worker ではなく Runtime/host 側が保持し、backend endpoint / auth / adapter selection / capability discovery を解決する。
|
||||||
|
- Runtime は `WorkspaceBackendRef` から scoped `WorkspaceClient` / handle を materialize し、Worker に注入する。
|
||||||
|
- Worker は `WorkspaceBackendRef` や secret を自分で解決せず、注入された narrow client/handle を通じて workspace-aware API に request する。
|
||||||
|
- `workspace_id` と `filesystem` を分離し、workspace id/client が存在しても local filesystem authority があるとは扱わない。
|
||||||
|
- workspace-aware 機能(Ticket / memory / workflow / project records / workspace metadata)は Backend API 経由で取得する。
|
||||||
|
- local path が必要な処理は Runtime/host の backend adapter、または `WorkerFilesystemAuthority::Local` の内側に閉じ込め、Worker workspace identity として露出しない。
|
||||||
|
|
||||||
|
## capability の扱い
|
||||||
|
|
||||||
|
Capability は最小実装では必須ではない。まずは Runtime/host 側が `workspace_id` から backend binding を解決し、backend 側の実 enforcement で成立する。
|
||||||
|
|
||||||
|
ただし capability は次の目的で有用である。
|
||||||
|
|
||||||
|
- Worker に登録する workspace-aware tools の表示・非表示を決める。
|
||||||
|
- read-only / read-write / unavailable の UX とエラーを fail-closed にする。
|
||||||
|
- Backend/API version 差分や remote backend の未対応機能を明示する。
|
||||||
|
- prompt/tool schema surface を不要に広げない。
|
||||||
|
|
||||||
|
一方で capability は client-declared authority にしてはいけない。最終的な権限判定は必ず backend が enforcement し、Worker 側 capability は advertised/discovered contract または UI/tool registration hint として扱う。
|
||||||
|
|
||||||
|
## 想定する型の方向性
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Worker が durable/context identity として持つもの。
|
||||||
|
struct WorkerContext {
|
||||||
|
workspace_id: Option<WorkspaceId>,
|
||||||
|
workspace_client: Option<WorkspaceClientHandle>,
|
||||||
|
filesystem: WorkerFilesystemAuthority,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime/host 側が保持・解決する backend binding source。
|
||||||
|
enum WorkspaceBackendRef {
|
||||||
|
None,
|
||||||
|
Rest {
|
||||||
|
base_url: Url,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
// Optional after minimal implementation:
|
||||||
|
// auth: Option<SecretRef>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime が Worker 起動時に materialize して注入する narrow client。
|
||||||
|
trait WorkspaceClient {
|
||||||
|
// ticket / memory / workflow / project-record API を必要に応じて提供する。
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`workspace_id` は Worker の durable identity であり、接続能力そのものではない。`WorkspaceBackendRef` / endpoint / secret / adapter は Runtime/host 側の責務とする。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- Worker context に workspace identity を path ではない `Option<WorkspaceId>` として保持できる。
|
||||||
|
- Worker struct に `workspace_root: PathBuf` field が存在しない。
|
||||||
|
- Worker の workspace identity accessor が local path を返さない。
|
||||||
|
- Runtime/host が `WorkspaceBackendRef` を保持・解決し、Worker に scoped `WorkspaceClient` / handle を注入できる。
|
||||||
|
- `workspace_id = None` と `filesystem = None` の会話専用 Worker を表現できる。
|
||||||
|
- `workspace_id = Some(...)` かつ `filesystem = None` の、workspace API は使えるが local filesystem は使えない Worker を表現できる。
|
||||||
|
- workspace-aware tools は注入された `WorkspaceClient` / handle を通して backend/API にアクセスし、local workspace path を authority として要求しない。
|
||||||
|
- Worker が `WorkspaceBackendRef` / REST endpoint / SecretRef を直接解決しない境界が明確である。
|
||||||
|
- Ticket / memory / workflow / project records / workspace metadata の Worker 側参照が `workspace_id` + `WorkspaceClient` 経由に移行している。
|
||||||
|
- local path backend が必要な場合でも、path は Runtime/host backend adapter 内または `WorkerFilesystemAuthority::Local` 内に閉じている。
|
||||||
|
- capability を導入する場合は backend-enforced authority ではなく tool registration / UX / discovery hint として扱われる。
|
||||||
|
- `WorkerFilesystemAuthority` チケットとの境界が明確で、filesystem authority と workspace identity/client が混同されない。
|
||||||
|
- `cargo test` と `nix build .#yoi` が通る。
|
||||||
32
.yoi/tickets/00001KX6Y6ZEA/resolution.md
Normal file
32
.yoi/tickets/00001KX6Y6ZEA/resolution.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
Worker workspace identity / WorkspaceBackend boundary を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Worker-held `workspace_root: PathBuf` identity を削除し、path-free `WorkerWorkspaceContext`, `WorkspaceId`, `WorkspaceClient` に置き換えた。
|
||||||
|
- Worker は `workspace_context` と `filesystem_authority` を分離して保持し、local filesystem authority は `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` に閉じ込めた。
|
||||||
|
- Worker constructors / restore / spawn paths を workspace context と filesystem authority の分離形に更新した。
|
||||||
|
- Runtime 側に `RuntimeWorkspaceBackendRef` を追加し、host-owned local backend binding から narrow Worker workspace context を注入するようにした。
|
||||||
|
- `workspace_id/client` が存在しても local filesystem authority を意味しないようにした。
|
||||||
|
- no-filesystem Workers では memory/workflow local layouts を workspace path fallback せず unavailable/fail-closed にした。
|
||||||
|
- legacy metadata `workspace_root` は compatibility/local filesystem hint として、`WorkerFilesystemAuthority::Local` 由来の場合だけ扱うようにした。
|
||||||
|
- tests で path-free workspace identity/client と local cwd/root の分離、filesystem authority なしの workspace client、local path-returning Worker workspace accessor 不在、constructor/restore/spawn behavior を確認した。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- Reviewer approved with no blockers。
|
||||||
|
- Evidence included path-free Worker context, no Worker `workspace_root()` accessor, Runtime/host backend materialization boundary, workspace-client-without-filesystem tests, fail-closed local workspace features, and legacy metadata confinement。
|
||||||
|
- Non-blocking note: `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `391f11fc merge: workspace backend worker context`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
|
||||||
|
- `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt`
|
||||||
395
.yoi/tickets/00001KX6Y6ZEA/thread.md
Normal file
395
.yoi/tickets/00001KX6Y6ZEA/thread.md
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T21:16:22Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T21:23:27Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Worker 側が持つ workspace 情報は `Option<WorkspaceId>` に留める。`WorkspaceBackendRef`、endpoint、auth/SecretRef、adapter/client の materialization は Runtime/host 側の責務とし、Worker は Runtime から注入された scoped `WorkspaceClient` / handle 経由で workspace-aware API に request する。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: hare at: 2026-07-10T21:24:57Z -->
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
整理結果:
|
||||||
|
|
||||||
|
1. 00001KX6Y2A9Q WorkerFilesystemAuthority
|
||||||
|
- hard dependency なし。
|
||||||
|
- no-workdir を filesystem authority none として型で表現する基盤。
|
||||||
|
|
||||||
|
2. 00001KX6WVNPD Embedded no-workdir Worker authority policy
|
||||||
|
- depends_on: 00001KX6Y2A9Q。
|
||||||
|
- UI/API の workdir 任意化と embedded no-workdir tool surface 制御を実装する。
|
||||||
|
|
||||||
|
3. 00001KX6Y6ZEA WorkspaceBackend / workspace_id 分離
|
||||||
|
- depends_on: 00001KX6Y2A9Q。
|
||||||
|
- workspace_root path 依存を workspace_id + Runtime-injected WorkspaceClient へ移行する。
|
||||||
|
- embedded no-workdir MVP をブロックしない。
|
||||||
|
|
||||||
|
Hard dependencies は WorkerFilesystemAuthority を共通前提に限定する。WorkspaceBackend 分離は embedded no-workdir policy の MVP には related だが depends_on にはしない。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: hare at: 2026-07-10T22:00:16Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Clarification: this ticket should remove Worker-level `workspace_root: PathBuf`, not leave it as a staged authority surface. Local paths may remain only inside Runtime/host backend adapters or `WorkerFilesystemAuthority::Local`; Worker workspace identity is `Option<WorkspaceId>` and workspace operations go through an injected `WorkspaceClient` / handle.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T22:01:46Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:01:46Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: workspace-panel at: 2026-07-10T22:03:27Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T22:03:57Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: blocked_by_dependency
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Dashboard queue authorization was inspected, but this Ticket has an explicit typed `depends_on` relation to `00001KX6Y2A9Q`。
|
||||||
|
- `00001KX6Y2A9Q` is currently `inprogress` and is refactoring Worker filesystem authority / cwd removal / no-workdir boundaries。
|
||||||
|
- This Ticket replaces Worker `workspace_root` path usage with `WorkspaceBackend`, which builds on the same Worker struct/context/constructor boundary。
|
||||||
|
- Starting now would likely conflict with the active filesystem-authority refactor and obscure review boundaries。
|
||||||
|
- Therefore this routing pass leaves the Ticket `queued` and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / recent thread / artifacts。
|
||||||
|
- `TicketRelationQuery(00001KX6Y6ZEA)`: includes dependency on `00001KX6Y2A9Q`。
|
||||||
|
- `TicketList(inprogress)`: `00001KX6Y2A9Q` is the active inprogress Ticket。
|
||||||
|
- Orchestrator worktree status: clean。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Re-route this Ticket after `00001KX6Y2A9Q` is approved, merged, validated, and closed。
|
||||||
|
- Escalate only if a human explicitly requests combined implementation in the same branch/worktree。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-10T23:38:54Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: hold_queued_due_migration_boundary_conflict
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- 前提 `00001KX6Y2A9Q` は closed になったため dependency 自体は解消済み。
|
||||||
|
- ただし、この routing pass では `00001KX6WVNPD` を先に受理した。
|
||||||
|
- `00001KX6WVNPD` は embedded no-workdir policy MVP であり、WorkspaceBackend migration を必要としない小さい境界変更である。
|
||||||
|
- この Ticket は Worker `workspace_root` path を `WorkspaceBackend` に置き換える refactor で、Worker struct/context/constructor/runtime spawn/UI API 周辺に触れる。
|
||||||
|
- `00001KX6WVNPD` も同じ Worker create / embedded runtime / no-workdir authority surface に触るため、並行 branch で進めると conflict と review boundary 混濁の可能性が高い。
|
||||||
|
- よって、この Ticket は `queued` のまま保持し、`00001KX6WVNPD` が reviewed/merged/closed になってから再 routing する。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / relations / orchestration plan。
|
||||||
|
- `TicketRelationQuery(00001KX6Y6ZEA)`: prerequisite `00001KX6Y2A9Q` は closed。
|
||||||
|
- `TicketList(inprogress)`: routing pass 中に `00001KX6WVNPD` を accepted for implementation。
|
||||||
|
- Orchestrator worktree status: clean。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- `00001KX6WVNPD` の implementation/review/merge/close を完了後、この Ticket を再 routing する。
|
||||||
|
- 人間が combined branch を明示希望する場合のみ、この待機判断を再検討する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-07-11T00:15:05Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- 前提 `00001KX6Y2A9Q` は closed 済みで、`WorkerFilesystemAuthority::{None, Local}` と cwd 除去境界が merge 済み。
|
||||||
|
- 関連 `00001KX6WVNPD` も closed 済みで、embedded no-workdir Worker policy / UI/API optionality が merge 済み。
|
||||||
|
- この Ticket の typed `depends_on 00001KX6Y2A9Q` は解消済み。`00001KX6WVNPD` は `related` であり blocker ではなく、直前の conflict/migration-boundary waiting note もその Ticket の merge/close により解消済み。
|
||||||
|
- `TicketList(inprogress)` は 0 件で、Orchestrator worktree は clean。
|
||||||
|
- Ticket body/thread に Worker-level local path identity をなくし、Runtime/host-owned backend/client 境界へ移す binding decision が記録されている。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / relations / orchestration plan。
|
||||||
|
- Closed prerequisite `00001KX6Y2A9Q` and closed related policy Ticket `00001KX6WVNPD`。
|
||||||
|
- `TicketRelationQuery(00001KX6Y6ZEA)`: outgoing `depends_on 00001KX6Y2A9Q` is closed; other relations are `related` only。
|
||||||
|
- `TicketOrchestrationPlanQuery(00001KX6Y6ZEA)`: prior waiting notes were dependency/conflict notes now resolved by prerequisite and related Ticket closure。
|
||||||
|
- `TicketList(inprogress)`: 0 件。
|
||||||
|
- Orchestrator worktree status: clean。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
|
||||||
|
Intent:
|
||||||
|
- Worker の workspace identity/context を local path (`workspace_root: PathBuf`) から path-free identity (`Option<WorkspaceId>`) と Runtime/host injected workspace client/handle 境界へ移行する。
|
||||||
|
- Filesystem authority は `WorkerFilesystemAuthority` に閉じ込め、workspace identity/client が local filesystem authority を意味しないようにする。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- Worker struct は durable/context identity として local workspace path を保持しない。
|
||||||
|
- Worker-level `workspace_root: PathBuf` field と local path-returning workspace identity accessor を削除する。
|
||||||
|
- Worker が `WorkspaceBackendRef` / endpoint / auth / SecretRef / adapter を直接解決しない。
|
||||||
|
- Runtime/host 側が backend binding source を保持・解決し、Worker には narrow `WorkspaceClient` / handle を注入する。
|
||||||
|
- `workspace_id = Some(...)` かつ `filesystem = None` を表現できること。
|
||||||
|
- `workspace_id = None` かつ `filesystem = None` の会話専用 Worker も表現できること。
|
||||||
|
- local path が必要な処理は Runtime/host backend adapter または `WorkerFilesystemAuthority::Local` の内側に閉じ込める。
|
||||||
|
- capability を導入する場合でも authority source にせず、tool registration / UX / discovery hint として扱い、enforcement は backend 側に置く。
|
||||||
|
- `WorkerFilesystemAuthority` / embedded no-workdir policy を崩さない。workspace root fallback や process cwd fallback を filesystem authority として復活させない。
|
||||||
|
|
||||||
|
Requirements / acceptance criteria:
|
||||||
|
- Worker context に `workspace_id: Option<WorkspaceId>` equivalent と injected workspace client/handle equivalent を持てる。
|
||||||
|
- Worker struct に `workspace_root: PathBuf` が存在しない。
|
||||||
|
- workspace-aware features(Ticket / memory / workflow / project records / workspace metadata)は local workspace path authority ではなく injected workspace client/handle 経由へ移行する、または移行が必要な箇所を fail-closed / unavailable diagnostic にする。
|
||||||
|
- Runtime/host が `WorkspaceBackendRef` equivalent を保持・解決し、Worker launch/restore/embedded/spawn path へ安全に materialize できる。
|
||||||
|
- No-workdir Worker は workspace API access と local filesystem authority の有無を独立に表現できる。
|
||||||
|
- Existing local/workdir Workers still work with `WorkerFilesystemAuthority::Local` and local-path-only operations confined there。
|
||||||
|
- Tests cover no local workspace path identity on Worker, workspace_id/client injection shape, no filesystem authority mixing, and representative workspace-aware API/tool behavior。
|
||||||
|
|
||||||
|
Implementation latitude:
|
||||||
|
- `WorkspaceId`, `WorkspaceBackendRef`, `WorkspaceClient` / handle の exact placement/name/API surface は existing crate boundaries に合わせてよい。
|
||||||
|
- Minimal client/handle can support only currently needed workspace-aware operations if broad API migration would exceed Ticket scope, but it must not leave Worker-local path authority as the active path。
|
||||||
|
- If a currently path-backed feature cannot be migrated safely in this Ticket, prefer explicit unavailable/fail-closed diagnostic with follow-up note rather than hidden path fallback。
|
||||||
|
- Capability discovery is optional unless needed for safe tool registration/UX。
|
||||||
|
|
||||||
|
Escalate if:
|
||||||
|
- Removing `workspace_root` from Worker requires changing product semantics for memory/Ticket/workflow availability beyond local refactor。
|
||||||
|
- A workspace-aware feature cannot be migrated without introducing a broad external backend/protocol design not captured by this Ticket。
|
||||||
|
- You need to reintroduce Worker-held local workspace path as identity/authority。
|
||||||
|
- You need to weaken no-workdir fs/Bash tool omission or `WorkerFilesystemAuthority` invariants。
|
||||||
|
- Public API/serialization migration requires incompatible durable-state handling beyond existing restore/test coverage。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `rg "workspace_root" crates/worker crates/worker-runtime crates/yoi-pod-client crates/yoi-workspace-server` with expected remaining hits only outside Worker identity/authority or documented adapter boundaries。
|
||||||
|
- `rg "worker\.workspace_root|workspace_root\(\)" crates` or equivalent showing Worker local path accessor removed。
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check && deno task test` if API/types/UI are touched。
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Current code map / likely touch points:
|
||||||
|
- `crates/worker/src/worker.rs` for Worker context fields and accessors。
|
||||||
|
- `crates/worker/src/controller.rs` for workspace-aware tool/resource setup currently tied to workspace path。
|
||||||
|
- `crates/worker-runtime/src/worker_backend.rs` for Runtime/embedded/restore construction and backend binding materialization。
|
||||||
|
- workspace-server/client crates if backend/client DTOs or Worker launch summaries expose workspace identity。
|
||||||
|
- tests around no-workdir Workers, restore, child spawn, Ticket/memory/workflow availability。
|
||||||
|
|
||||||
|
Critical risks / reviewer focus:
|
||||||
|
- Hidden local path authority retained in Worker under a renamed field/accessor。
|
||||||
|
- Workspace identity/client being treated as filesystem authority。
|
||||||
|
- WorkspaceBackendRef/endpoint/secret leaking into Worker-owned state。
|
||||||
|
- Breaking local/workdir Workers or child spawn/restore while removing path identity。
|
||||||
|
- Regressing embedded no-workdir policy or filesystem/Bash tool omission。
|
||||||
|
- Over-broad migration that invents a large remote-workspace protocol instead of the minimal safe boundary needed here。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-11T00:15:11Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Prerequisite and prior conflict/migration-boundary Ticket are now closed. This Ticket is accepted for implementation before creating a worktree or spawning role Pods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-11T00:59:55Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation progress report:
|
||||||
|
|
||||||
|
- Coder Pod `yoi-coder-00001KX6Y6ZEA-workspace-backend` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6Y6ZEA-workspace-backend` on branch `work/00001KX6Y6ZEA-workspace-backend`。
|
||||||
|
- Implementation commit: `142b60e1b3ca refactor: separate worker workspace identity`。
|
||||||
|
- Orchestrator inspected worktree status, branch log, commit stats, `git diff --check 74b07bd7..HEAD`, and workspace-root accessor grep. Worktree was clean and diff check passed。
|
||||||
|
|
||||||
|
Implementation summary:
|
||||||
|
- Replaced Worker-held `workspace_root: PathBuf` identity with path-free `WorkerWorkspaceContext`, `WorkspaceId`, and `WorkspaceClient`。
|
||||||
|
- Kept local filesystem authority under `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}`。
|
||||||
|
- Updated Worker constructors / restore / spawn paths to receive workspace context separately from filesystem authority。
|
||||||
|
- Added runtime-side `RuntimeWorkspaceBackendRef` as host-owned local backend binding that injects narrow Worker workspace context。
|
||||||
|
- No-filesystem Workers keep memory/workflow local layouts unavailable/fail-closed rather than falling back to workspace paths。
|
||||||
|
- Preserved legacy metadata `workspace_root` only as a local filesystem hint derived from `WorkerFilesystemAuthority::Local`, not as Worker identity。
|
||||||
|
- Updated tests for path-free workspace identity/client separation from local tool cwd/root, workspace client without filesystem authority, no local path-returning Worker workspace accessor, and constructor usage。
|
||||||
|
|
||||||
|
Files touched:
|
||||||
|
- `crates/worker/src/worker.rs`
|
||||||
|
- `crates/worker/src/controller.rs`
|
||||||
|
- `crates/worker/src/entrypoint.rs`
|
||||||
|
- `crates/worker/src/lib.rs`
|
||||||
|
- `crates/worker/src/discovery.rs`
|
||||||
|
- `crates/worker/src/ticket_event_notify.rs`
|
||||||
|
- `crates/worker/tests/*`
|
||||||
|
- `crates/worker-runtime/src/worker_backend.rs`
|
||||||
|
- `crates/worker-runtime/Cargo.toml`
|
||||||
|
- `crates/session-store/src/worker_metadata.rs`
|
||||||
|
- `Cargo.lock`
|
||||||
|
- `package.nix`
|
||||||
|
|
||||||
|
Coder-reported validation passed:
|
||||||
|
- `rg -n "workspace_root" crates/worker crates/worker-runtime crates/client crates/workspace-server` with remaining hits as legacy metadata/local filesystem adapter or host/runtime local backend boundaries plus tests。
|
||||||
|
- `rg -n "worker\\.workspace_root|pub fn workspace_root|fn workspace_root" crates/worker crates/worker-runtime crates/client crates/workspace-server || true`; no Worker `workspace_root()` accessor hits. Orchestrator grep observed an unrelated `crates/workspace-server/src/records.rs` project-record accessor hit outside Worker identity。
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Not run by Coder:
|
||||||
|
- `cd web/workspace && deno task check && deno task test` because web/API/types were not touched。
|
||||||
|
|
||||||
|
Next action:
|
||||||
|
- Route to external Reviewer Pod before merge/close decisions。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: reviewer at: 2026-07-11T01:07:06Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
External review result: approve
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Worker identity is path-free: `WorkerWorkspaceContext` carries `Option<WorkspaceId>` plus narrow `WorkspaceClient`/unavailable state, while `Worker` stores `workspace_context` and `filesystem_authority` instead of a `workspace_root: PathBuf` (`crates/worker/src/worker.rs:61-109`, `384-405`)。
|
||||||
|
- No `worker.workspace_root()` / local path-returning workspace accessor remains; local paths are exposed only through `WorkerFilesystemAuthority::Local` helpers such as `local_working_directory()` (`worker.rs:741-840`)。
|
||||||
|
- Runtime/host boundary now resolves local workspace backend context before constructing Workers (`crates/worker-runtime/src/worker_backend.rs:321-390`), and Worker construction accepts injected workspace context + filesystem authority (`worker.rs:4219-4260`)。
|
||||||
|
- Workspace/client without filesystem and no-workspace/no-filesystem Workers are represented and covered by tests (`worker.rs:5441-5530`; worker-runtime tests around `901+`)。
|
||||||
|
- Workspace-aware local features fail closed when no local filesystem authority is available; Ticket/memory/workflow/spawn-dependent surfaces require `filesystem_authority.local()` and otherwise return unavailable errors rather than cwd/root fallback (`crates/worker/src/controller.rs:630-672`, `709-779`)。
|
||||||
|
- Legacy `workspace_root` metadata is written only as a compatibility/local hint from local filesystem authority, not as Worker identity (`worker.rs:4604-4621`; `session-store/src/worker_metadata.rs`)。
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- `git diff --check 142b60e1^ 142b60e1`
|
||||||
|
- Focused grep for forbidden `workspace_root()` accessor: no matches。
|
||||||
|
- Focused tests:
|
||||||
|
- `cargo test -p worker spawned_context_tests --lib`
|
||||||
|
- `cargo test -p worker-runtime worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo --features ws-server,fs-store`
|
||||||
|
- `cargo test -p worker-runtime adapter_dispatches_user_input_through_worker_run_lifecycle --features ws-server,fs-store`
|
||||||
|
|
||||||
|
Blockers: none。
|
||||||
|
|
||||||
|
Non-blocking notes:
|
||||||
|
- Remaining `workspace_root` hits are local runtime/entrypoint/spawn-tool adapter, legacy metadata, or test contexts; reviewer did not find Worker identity/accessor use。
|
||||||
|
- `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; acceptable for this Ticket as compatibility metadata, but future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-07-11T01:09:39Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Merge / final validation report:
|
||||||
|
|
||||||
|
- Reviewer approved the implementation with no blockers。
|
||||||
|
- Merged branch `work/00001KX6Y6ZEA-workspace-backend` into orchestration branch with merge commit `391f11fc merge: workspace backend worker context`。
|
||||||
|
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
- `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true` with remaining hits limited to legacy metadata/local adapter/test contexts。
|
||||||
|
- `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`; no Worker identity/accessor hits were found. The expected unrelated workspace-server project-record accessor remains outside Worker identity。
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
|
||||||
|
Validation log:
|
||||||
|
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt`
|
||||||
|
|
||||||
|
Reviewed/merged implementation commit:
|
||||||
|
- `142b60e1 refactor: separate worker workspace identity`
|
||||||
|
|
||||||
|
Non-blocking reviewer note:
|
||||||
|
- `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; acceptable for this Ticket as compatibility metadata, but future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-07-11T01:09:43Z from: inprogress to: done reason: implementation_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T01:09:59Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T01:09:59Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Worker workspace identity / WorkspaceBackend boundary を実装・レビュー・merge・検証した。
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Worker-held `workspace_root: PathBuf` identity を削除し、path-free `WorkerWorkspaceContext`, `WorkspaceId`, `WorkspaceClient` に置き換えた。
|
||||||
|
- Worker は `workspace_context` と `filesystem_authority` を分離して保持し、local filesystem authority は `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` に閉じ込めた。
|
||||||
|
- Worker constructors / restore / spawn paths を workspace context と filesystem authority の分離形に更新した。
|
||||||
|
- Runtime 側に `RuntimeWorkspaceBackendRef` を追加し、host-owned local backend binding から narrow Worker workspace context を注入するようにした。
|
||||||
|
- `workspace_id/client` が存在しても local filesystem authority を意味しないようにした。
|
||||||
|
- no-filesystem Workers では memory/workflow local layouts を workspace path fallback せず unavailable/fail-closed にした。
|
||||||
|
- legacy metadata `workspace_root` は compatibility/local filesystem hint として、`WorkerFilesystemAuthority::Local` 由来の場合だけ扱うようにした。
|
||||||
|
- tests で path-free workspace identity/client と local cwd/root の分離、filesystem authority なしの workspace client、local path-returning Worker workspace accessor 不在、constructor/restore/spawn behavior を確認した。
|
||||||
|
|
||||||
|
Review:
|
||||||
|
- Reviewer approved with no blockers。
|
||||||
|
- Evidence included path-free Worker context, no Worker `workspace_root()` accessor, Runtime/host backend materialization boundary, workspace-client-without-filesystem tests, fail-closed local workspace features, and legacy metadata confinement。
|
||||||
|
- Non-blocking note: `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
|
||||||
|
|
||||||
|
Merge / validation:
|
||||||
|
- Merge commit: `391f11fc merge: workspace backend worker context`。
|
||||||
|
- Final validation passed:
|
||||||
|
- `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
|
||||||
|
- `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
|
||||||
|
- `git diff --check`
|
||||||
|
- `cargo test -p worker --lib --tests`
|
||||||
|
- `cargo test -p worker-runtime --features ws-server,fs-store`
|
||||||
|
- `cargo test -p yoi-workspace-server --lib`
|
||||||
|
- `cargo check -p yoi`
|
||||||
|
- `cd web/workspace && deno task check`
|
||||||
|
- `cd web/workspace && deno task test`
|
||||||
|
- `yoi ticket doctor`
|
||||||
|
- `nix build .#yoi --no-link`
|
||||||
|
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt`
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX7179VS/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX7179VS/artifacts/.gitkeep
Normal file
31
.yoi/tickets/00001KX7179VS/item.md
Normal file
31
.yoi/tickets/00001KX7179VS/item.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
title: 'Improve workspace console tool call rendering'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-10T22:08:58Z'
|
||||||
|
updated_at: '2026-07-10T22:28:24Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-10T22:09:21Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workspace frontend の Worker Console では、tool call と tool result が別々の行として表示され、streaming 中も `tool call · Bash` / `tool call done · Bash` / `tool result` のように分断されて見える。TUI では tool call を `Call` ブロックとしてまとめ、状態更新と結果を同一ブロック内で表示しているため、Frontend Console も同じ情報設計に寄せる。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- Workspace Console の tool call 表示を、call 単位の1ブロックに集約する。
|
||||||
|
- streaming / done / failed などの状態変化は同じブロック内で更新して見せる。
|
||||||
|
- tool result は対応する tool call ブロックに取り込み、独立した `tool result` 行として重複表示しない。
|
||||||
|
- TUI の汎用 tool 表示と、Bash / Read / Write / Edit / Glob / Grep / WebFetch / WebSearch 等の専用表示方針を確認し、Frontend 側でも少なくとも Bash など主要 tool の要約表示を揃える。
|
||||||
|
- call id が取れない・対応する call が見つからない result は、安全な fallback 表示を維持する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- Console で `Bash { command: "pwd" }` の tool call と result が1つの Call ブロックとして表示される。
|
||||||
|
- streaming 中の tool call は同じブロックで `streaming` 状態を表示し、完了時に同じブロックが完了状態へ更新される。
|
||||||
|
- tool result の content が対応する Call ブロック内に表示される。
|
||||||
|
- 汎用 tool では tool 名、引数、状態、結果を同一ブロック内で確認できる。
|
||||||
|
- Bash など主要 tool では TUI と同じ方針の専用 summary が表示される。
|
||||||
|
- Console model の unit test で tool call/result の集約を確認する。
|
||||||
|
- `cd web/workspace && deno task check` と `cd web/workspace && deno task test` が通る。
|
||||||
1
.yoi/tickets/00001KX7179VS/resolution.md
Normal file
1
.yoi/tickets/00001KX7179VS/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Implemented frontend Workspace Console tool call grouping. Tool call start/arg/done/result events now project into a single `Call · <tool>` block keyed by call id, with streaming state updates and folded result output. Added generic and TUI-aligned specialized summaries for core tools, including Bash command/output rendering. Validated with frontend check/test, diff check, and Nix package build.
|
||||||
105
.yoi/tickets/00001KX7179VS/thread.md
Normal file
105
.yoi/tickets/00001KX7179VS/thread.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T22:08:58Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T22:09:20Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:09:20Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:09:21Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:09:21Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-10T22:16:58Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented Workspace Console tool-call grouping.
|
||||||
|
|
||||||
|
- Tool lifecycle events now update one `Call · <tool>` console line keyed by call id.
|
||||||
|
- Args streaming, call completion, and tool result output are folded into that line instead of producing separate `tool call` / `tool call done` / `tool result` rows.
|
||||||
|
- Added generic tool rendering plus TUI-aligned special summaries for Read, Write, Edit, Glob, and Grep; Bash renders the command and output in the same Call block.
|
||||||
|
- In-flight tool snapshots now render as Call blocks as well.
|
||||||
|
- Added model tests for completed Bash call aggregation and streaming Read call updates.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:16:58Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T22:17:10Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T22:17:10Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Implemented frontend Workspace Console tool call grouping. Tool call start/arg/done/result events now project into a single `Call · <tool>` block keyed by call id, with streaming state updates and folded result output. Added generic and TUI-aligned specialized summaries for core tools, including Bash command/output rendering. Validated with frontend check/test, diff check, and Nix package build.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-10T22:28:24Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up correction: Read rendering now matches the TUI special case more closely. Consecutive Read calls are aggregated into a single `Call · Read` block, and file contents / tool output are not rendered in the console body. The aggregate lists paths and state only, preserving summaries in detail.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX72G8Z6/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX72G8Z6/artifacts/.gitkeep
Normal file
32
.yoi/tickets/00001KX72G8Z6/item.md
Normal file
32
.yoi/tickets/00001KX72G8Z6/item.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
title: 'Add rich markdown tool rendering to workspace console'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-10T22:31:21Z'
|
||||||
|
updated_at: '2026-07-10T22:46:12Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-10T22:31:41Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workspace Console の Call ブロック化後も、表示は plain text `<pre>` 中心で、TUI の色分け・Edit diff・Markdown/code block 表示が反映されていない。Console は TUI と同じ情報設計に寄せ、Assistant text や tool output を Markdown/HTML として読みやすく表示し、fenced code block は Shiki で highlight する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- TUI tool rendering の色分け方針を Frontend Console に移植する。
|
||||||
|
- Assistant/tool body を Markdown から安全な HTML に変換して表示する。
|
||||||
|
- fenced code block は Shiki で syntax highlight する。
|
||||||
|
- Edit tool は old/new 文字列の diff を専用表示し、削除/追加/文脈行を色分けする。
|
||||||
|
- Read tool aggregate は引き続き file content を body に表示しない。
|
||||||
|
- Console model tests と UI/rendering tests を更新する。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- Console で assistant markdown が HTML として表示される。
|
||||||
|
- fenced code block が Shiki の highlighted HTML として表示される。
|
||||||
|
- Console tool blocks に TUI 相当の状態色・tool 色・diff 色が適用される。
|
||||||
|
- Edit tool が old/new から diff block を生成し、削除行と追加行を別色で表示する。
|
||||||
|
- Read aggregate は複数 Read をまとめ、file content を表示しない。
|
||||||
|
- `cd web/workspace && deno task check` と `cd web/workspace && deno task test` が通る。
|
||||||
|
- `nix build .#yoi` が通る。
|
||||||
1
.yoi/tickets/00001KX72G8Z6/resolution.md
Normal file
1
.yoi/tickets/00001KX72G8Z6/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Added rich Workspace Console rendering: safe markdown HTML output, Shiki-highlighted fenced code blocks, TUI-inspired coloring for message/tool state, and structured Edit diff rows. Read aggregation continues to avoid displaying file content. Validation passed with frontend check/test, diff check, and Nix build.
|
||||||
91
.yoi/tickets/00001KX72G8Z6/thread.md
Normal file
91
.yoi/tickets/00001KX72G8Z6/thread.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T22:31:21Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T22:31:40Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:31:40Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:31:41Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:31:41Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-10T22:45:55Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented rich Workspace Console rendering.
|
||||||
|
|
||||||
|
- Added safe Markdown-to-HTML rendering for console bodies; raw HTML is escaped.
|
||||||
|
- Added Shiki highlighting for fenced code blocks.
|
||||||
|
- Added TUI-inspired Console colors for message kinds, tool types, tool states, and diff rows.
|
||||||
|
- Added structured Edit diff projection using old_string/new_string and rendered add/remove/context rows.
|
||||||
|
- Kept Read aggregate behavior: consecutive Read calls are grouped and file content is not shown in the body.
|
||||||
|
- Added tests for Markdown escaping/Shiki rendering and Edit diff projection.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:45:55Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T22:46:12Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T22:46:12Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Added rich Workspace Console rendering: safe markdown HTML output, Shiki-highlighted fenced code blocks, TUI-inspired coloring for message/tool state, and structured Edit diff rows. Read aggregation continues to avoid displaying file content. Validation passed with frontend check/test, diff check, and Nix build.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX7645ZS/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX7645ZS/artifacts/.gitkeep
Normal file
30
.yoi/tickets/00001KX7645ZS/item.md
Normal file
30
.yoi/tickets/00001KX7645ZS/item.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
title: 'Remove backend worker observation replay'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-10T23:34:39Z'
|
||||||
|
updated_at: '2026-07-10T23:50:55Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-10T23:35:04Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workspace Console の worker observation WebSocket は、BackendObservationProxy の保存済み history を接続時に replay している。そのためページ読み込み直後に Snapshot の後で過去の `user_message` などが WS frame として流れ、Console では「過去イベントがストリームされている」ように見える。
|
||||||
|
|
||||||
|
Console attach は現在状態の Snapshot と以後の live observation を見たいのであって、backend-local replay は不要。Snapshot は bootstrap、以後の event は live のみとする。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- workspace-server の worker observation WebSocket から backend-local replay を削除する。
|
||||||
|
- BackendObservationProxy は replay history を持たず、必要なら runtime cursor の引き継ぎだけを扱う。
|
||||||
|
- Snapshot を backend history として保存・再送しない。
|
||||||
|
- Frontend Console 接続時に、Snapshot 後に過去の `user_message` / `tool_result` 等が replay されない。
|
||||||
|
- Runtime から live に届く observation event は従来通り browser に転送される。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- worker observation WS 接続時、最初に Snapshot が1回届き、その後は接続後に発生した live events のみ届く。
|
||||||
|
- backend-local replay 用の history / cursor validation / cursor unknown handling が削除または無効化される。
|
||||||
|
- 既存の proxy WS tests を replay 前提から snapshot + live 前提に更新する。
|
||||||
|
- `cargo test` と `nix build .#yoi` が通る。
|
||||||
1
.yoi/tickets/00001KX7645ZS/resolution.md
Normal file
1
.yoi/tickets/00001KX7645ZS/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Removed backend-local worker observation replay. Worker Console attach now receives Runtime Snapshot as bootstrap and then live upstream events only; workspace-server no longer stores/replays previous user_message/tool_result/text_delta frames.
|
||||||
89
.yoi/tickets/00001KX7645ZS/thread.md
Normal file
89
.yoi/tickets/00001KX7645ZS/thread.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-10T23:34:39Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-10T23:35:04Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T23:35:04Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T23:35:04Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T23:35:04Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-10T23:50:49Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Removed backend-local worker observation replay.
|
||||||
|
|
||||||
|
- BackendObservationProxy no longer stores/replays event history.
|
||||||
|
- Browser worker observation WS connects upstream directly after validating cursor syntax; any cursor is no longer matched against backend replay history.
|
||||||
|
- Runtime Snapshot remains the connection bootstrap, and subsequent frames are live upstream events only.
|
||||||
|
- Updated proxy tests to assert fresh connections receive Snapshot and not previously stored live events.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cargo test -q -p yoi-workspace-server
|
||||||
|
- cargo test -q
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T23:50:50Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-10T23:50:55Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-10T23:50:55Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Removed backend-local worker observation replay. Worker Console attach now receives Runtime Snapshot as bootstrap and then live upstream events only; workspace-server no longer stores/replays previous user_message/tool_result/text_delta frames.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX7838M2/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX7838M2/artifacts/.gitkeep
Normal file
29
.yoi/tickets/00001KX7838M2/item.md
Normal file
29
.yoi/tickets/00001KX7838M2/item.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
title: 'Remove worker observation WebSocket cursor surface'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-11T00:09:06Z'
|
||||||
|
updated_at: '2026-07-11T00:31:57Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-11T00:09:32Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Workspace worker observation WebSocket には backend-local `cursor` / `event_id` surface が残っているが、backend replay を削除した後は resume cursor として意味を持たない。Console attach は Snapshot + live events のみで十分であり、未読 output 読み取りが必要な場合は WebSocket cursor ではなく durable output/log 専用 cursor を別設計する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- workspace-server の worker observation browser-facing WebSocket から backend-local cursor surface を削除する。
|
||||||
|
- `ClientWorkerEventsWsQuery.cursor` / `BackendObservationCursor` / backend `next_sequence` / `open()` / `store()` を削除または単純な envelope mapping に縮小する。
|
||||||
|
- browser-facing event envelope は Runtime/Worker identity と payload に限定し、存在するなら runtime event id のみを重複抑止用に渡す。
|
||||||
|
- Frontend Console は backend cursor に依存せず、runtime event id で dedupe する。
|
||||||
|
- cursor 付き WS 接続は API surface として提供しない。クエリを無視する設計にせず、不要な型・検証・診断を消す。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- worker observation WS response frame に backend-local cursor が含まれない。
|
||||||
|
- Frontend Console model / route が cursor を保持・利用しない。
|
||||||
|
- backend-local cursor 関連型と cursor malformed/unknown handling が削除される。
|
||||||
|
- Runtime observation cursor は runtime 内部または runtime endpoint だけの概念として残り、workspace-server browser surface には露出しない。
|
||||||
|
- `cargo test`、`cd web/workspace && deno task check`、`cd web/workspace && deno task test`、`nix build .#yoi` が通る。
|
||||||
1
.yoi/tickets/00001KX7838M2/resolution.md
Normal file
1
.yoi/tickets/00001KX7838M2/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Removed browser-facing worker observation cursor support. Workspace Console and Backend Runtime clients now attach to Snapshot + live events without backend cursor query or backend-local cursor state. Event envelopes expose runtime event id for dedupe only.
|
||||||
93
.yoi/tickets/00001KX7838M2/thread.md
Normal file
93
.yoi/tickets/00001KX7838M2/thread.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-11T00:09:06Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-11T00:09:32Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T00:09:32Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T00:09:32Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T00:09:33Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-11T00:31:41Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Removed the backend-local worker observation cursor surface.
|
||||||
|
|
||||||
|
- Browser-facing worker observation envelopes no longer include `cursor`; they carry runtime/worker identity, runtime event id, and payload.
|
||||||
|
- Workspace-server observation proxy no longer owns cursor state, cursor validation, replay state, or store/open methods; it only maps upstream Runtime events to browser envelopes.
|
||||||
|
- Workspace-server WS attach ignores no cursor path because no cursor query is accepted/extracted.
|
||||||
|
- Backend Runtime client no longer sends cursor query params or deduplicates by backend cursor.
|
||||||
|
- Frontend Console no longer stores cursor/lastCursor and projects events by runtime event id.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cargo test -q -p yoi-workspace-server
|
||||||
|
- cargo test -q -p client
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- cargo test -q
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T00:31:41Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T00:31:57Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T00:31:57Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Removed browser-facing worker observation cursor support. Workspace Console and Backend Runtime clients now attach to Snapshot + live events without backend cursor query or backend-local cursor state. Event envelopes expose runtime event id for dedupe only.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
0
.yoi/tickets/00001KX7NS2PD/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KX7NS2PD/artifacts/.gitkeep
Normal file
30
.yoi/tickets/00001KX7NS2PD/item.md
Normal file
30
.yoi/tickets/00001KX7NS2PD/item.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
title: 'Remove durable worker lifecycle_state'
|
||||||
|
state: 'closed'
|
||||||
|
created_at: '2026-07-11T04:08:12Z'
|
||||||
|
updated_at: '2026-07-11T04:34:08Z'
|
||||||
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-11T04:09:06Z'
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Backend worker registry は `lifecycle_state` を永続化しているが、`running` / `idle` / `stopped` などの Worker lifecycle は Runtime が authority を持つ live state であり、Backend DB に保存すると Runtime 再起動後に stale な state が Workers list に残る。Registry は Worker identity / refs / retention を保存し、live state は Runtime 問い合わせまたは短命 cache に限定する。
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
- `WorkerRegistryRecord` と `worker_registry` schema から `lifecycle_state` を削除する。
|
||||||
|
- worker registry upsert/select/read の SQL から `lifecycle_state` を削除する。
|
||||||
|
- registry-only Worker projection は過去の lifecycle state を status/state として返さず、`archived` / `registry_only` 等の live ではない状態として表現する。
|
||||||
|
- live Worker が存在する場合は Runtime 由来の status/state をそのまま使い、registry metadata は label/profile/retention/working-directory refs などに限定して merge する。
|
||||||
|
- 既存 DB に `lifecycle_state` column が残っていても読み書きが壊れないよう、active schema/access path はその column に依存しない。
|
||||||
|
|
||||||
|
## 受け入れ条件
|
||||||
|
|
||||||
|
- `WorkerRegistryRecord` に `lifecycle_state` field が存在しない。
|
||||||
|
- `worker_registry` の active CREATE TABLE / INSERT / SELECT / read path に `lifecycle_state` が存在しない。
|
||||||
|
- `merge_worker_registry_projection(Some(live), ...)` が live status/state を registry value で上書きしない。
|
||||||
|
- `worker_summary_from_registry()` が `idle` / `running` / `stopped` など過去の live lifecycle を返さない。
|
||||||
|
- Workspace server tests が registry-only Worker と live Worker merge の両方を検証する。
|
||||||
|
- `cargo test` と `nix build .#yoi` が通る。
|
||||||
1
.yoi/tickets/00001KX7NS2PD/resolution.md
Normal file
1
.yoi/tickets/00001KX7NS2PD/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Removed durable `lifecycle_state` from Backend worker registry. Runtime remains the authority for live status/state; registry-only Workers project as archived, and cleanup planning uses live Runtime worker observation for running-linked checks. Existing DBs are migrated by schema v4 to remove the legacy column.
|
||||||
94
.yoi/tickets/00001KX7NS2PD/thread.md
Normal file
94
.yoi/tickets/00001KX7NS2PD/thread.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-07-11T04:08:12Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: intake_summary author: hare at: 2026-07-11T04:09:06Z -->
|
||||||
|
|
||||||
|
## Intake summary
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T04:09:06Z from: planning to: ready reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Marked ready by `yoi ticket state`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T04:09:06Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T04:09:06Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-11T04:33:35Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Removed durable worker lifecycle state from the Backend worker registry.
|
||||||
|
|
||||||
|
- `WorkerRegistryRecord` no longer has `lifecycle_state`.
|
||||||
|
- Active worker_registry CREATE/INSERT/SELECT/read paths no longer include lifecycle state.
|
||||||
|
- Added schema migration v4 to rebuild legacy worker_registry tables without the old lifecycle column.
|
||||||
|
- Registry-only Worker projection now reports `archived` instead of stale `idle`/`running`/`stopped` values.
|
||||||
|
- Live Worker merge no longer lets registry data overwrite Runtime status/state.
|
||||||
|
- Cleanup planning uses live Runtime workers for running-linked detection; registry records only provide retention/link metadata.
|
||||||
|
- Removed lifecycle_state from frontend cleanup candidate types/display.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cargo test -q -p yoi-workspace-server
|
||||||
|
- cd web/workspace && deno task check
|
||||||
|
- cd web/workspace && deno task test
|
||||||
|
- cargo test -q
|
||||||
|
- git diff --check
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T04:33:35Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-11T04:34:08Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-11T04:34:08Z status: closed -->
|
||||||
|
|
||||||
|
## 完了
|
||||||
|
|
||||||
|
Removed durable `lifecycle_state` from Backend worker registry. Runtime remains the authority for live status/state; registry-only Workers project as archived, and cleanup planning uses live Runtime worker observation for running-linked checks. Existing DBs are migrated by schema v4 to remove the legacy column.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
103
Cargo.lock
generated
103
Cargo.lock
generated
|
|
@ -1060,17 +1060,6 @@ version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "erased-serde"
|
|
||||||
version = "0.4.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
"typeid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
|
|
@ -2206,25 +2195,6 @@ version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lua-src"
|
|
||||||
version = "550.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "luajit-src"
|
|
||||||
version = "210.6.6+707c12b"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"which",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -2256,7 +2226,6 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"llm-engine",
|
"llm-engine",
|
||||||
"mlua",
|
|
||||||
"protocol",
|
"protocol",
|
||||||
"secrets",
|
"secrets",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -2444,39 +2413,6 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mlua"
|
|
||||||
version = "0.11.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab"
|
|
||||||
dependencies = [
|
|
||||||
"bstr",
|
|
||||||
"either",
|
|
||||||
"erased-serde",
|
|
||||||
"libc",
|
|
||||||
"mlua-sys",
|
|
||||||
"num-traits",
|
|
||||||
"parking_lot",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustversion",
|
|
||||||
"serde",
|
|
||||||
"serde-value",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mlua-sys"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"lua-src",
|
|
||||||
"luajit-src",
|
|
||||||
"pkg-config",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
|
|
@ -2671,15 +2607,6 @@ dependencies = [
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ordered-float"
|
|
||||||
version = "2.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
|
|
@ -3683,16 +3610,6 @@ dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde-value"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
|
||||||
dependencies = [
|
|
||||||
"ordered-float 2.10.1",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -4228,7 +4145,7 @@ dependencies = [
|
||||||
"nix",
|
"nix",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"ordered-float 4.6.0",
|
"ordered-float",
|
||||||
"pest",
|
"pest",
|
||||||
"pest_derive",
|
"pest_derive",
|
||||||
"phf 0.11.3",
|
"phf 0.11.3",
|
||||||
|
|
@ -4744,12 +4661,6 @@ dependencies = [
|
||||||
"pom",
|
"pom",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typeid"
|
|
||||||
version = "1.0.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|
@ -5428,7 +5339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
|
checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"ordered-float 4.6.0",
|
"ordered-float",
|
||||||
"strsim",
|
"strsim",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"wezterm-dynamic-derive",
|
"wezterm-dynamic-derive",
|
||||||
|
|
@ -5458,15 +5369,6 @@ dependencies = [
|
||||||
"wezterm-dynamic",
|
"wezterm-dynamic",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "which"
|
|
||||||
version = "8.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|
@ -5946,6 +5848,7 @@ dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"worker",
|
"worker",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@ use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use protocol::{ErrorCode, Event, Greeting, InFlightSnapshot, Method, Segment, WorkerStatus};
|
use protocol::{ErrorCode, Event, Method, Segment};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
|
|
||||||
const TRANSCRIPT_SNAPSHOT_LIMIT: usize = 512;
|
|
||||||
const RECONNECT_DELAY: Duration = Duration::from_millis(500);
|
const RECONNECT_DELAY: Duration = Duration::from_millis(500);
|
||||||
const MAX_RECONNECT_ATTEMPTS: usize = 3;
|
const MAX_RECONNECT_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
|
|
@ -80,31 +79,10 @@ impl BackendRuntimeClient {
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
let suppress_initial_snapshot = match load_initial_transcript(&http, &target).await {
|
|
||||||
Ok(events) => {
|
|
||||||
for event in events {
|
|
||||||
let _ = tx.send(event);
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend initial transcript unavailable for {}: {error}",
|
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let observation_target = target.clone();
|
let observation_target = target.clone();
|
||||||
let observation_tx = tx.clone();
|
let observation_tx = tx.clone();
|
||||||
let observation_task = tokio::spawn(async move {
|
let observation_task = tokio::spawn(async move {
|
||||||
observe_worker_events(
|
observe_worker_events(observation_target, observation_tx).await;
|
||||||
observation_target,
|
|
||||||
observation_tx,
|
|
||||||
suppress_initial_snapshot,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -291,86 +269,11 @@ fn backend_command_from_method(method: &Method) -> BackendCommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_initial_transcript(
|
async fn observe_worker_events(target: BackendRuntimeTarget, tx: mpsc::UnboundedSender<Event>) {
|
||||||
http: &reqwest::Client,
|
|
||||||
target: &BackendRuntimeTarget,
|
|
||||||
) -> Result<Vec<Event>, BackendRuntimeClientError> {
|
|
||||||
let path = format!(
|
|
||||||
"/api/runtimes/{}/workers/{}/transcript?start=0&limit={TRANSCRIPT_SNAPSHOT_LIMIT}",
|
|
||||||
path_segment_encode(&target.runtime_id),
|
|
||||||
path_segment_encode(&target.worker_id)
|
|
||||||
);
|
|
||||||
let response = http
|
|
||||||
.get(join_base_and_path(&target.base_url, &path))
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?;
|
|
||||||
let transcript: WorkerTranscriptProjection = response.json().await?;
|
|
||||||
Ok(transcript_projection_to_events(target, transcript))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transcript_projection_to_events(
|
|
||||||
target: &BackendRuntimeTarget,
|
|
||||||
transcript: WorkerTranscriptProjection,
|
|
||||||
) -> Vec<Event> {
|
|
||||||
let mut events = vec![Event::Snapshot {
|
|
||||||
entries: Vec::new(),
|
|
||||||
greeting: Greeting {
|
|
||||||
worker_name: target.worker_id.clone(),
|
|
||||||
cwd: String::new(),
|
|
||||||
provider: "backend-runtime-api".to_string(),
|
|
||||||
model: target.runtime_id.clone(),
|
|
||||||
scope_summary: "Backend Runtime API worker observation".to_string(),
|
|
||||||
tools: Vec::new(),
|
|
||||||
context_window: 0,
|
|
||||||
context_tokens: 0,
|
|
||||||
},
|
|
||||||
status: WorkerStatus::Idle,
|
|
||||||
in_flight: InFlightSnapshot { blocks: Vec::new() },
|
|
||||||
}];
|
|
||||||
|
|
||||||
for item in transcript.items {
|
|
||||||
match item.role.as_str() {
|
|
||||||
"user" => events.push(Event::UserMessage {
|
|
||||||
segments: vec![Segment::text(item.content)],
|
|
||||||
}),
|
|
||||||
"assistant" => {
|
|
||||||
events.push(Event::TextDelta {
|
|
||||||
text: item.content.clone(),
|
|
||||||
});
|
|
||||||
events.push(Event::TextDone { text: item.content });
|
|
||||||
}
|
|
||||||
role => events.push(Event::Alert(protocol::Alert {
|
|
||||||
level: protocol::AlertLevel::Warn,
|
|
||||||
source: protocol::AlertSource::Worker,
|
|
||||||
message: format!(
|
|
||||||
"Backend transcript item with role `{role}` is not rendered as chat content"
|
|
||||||
),
|
|
||||||
timestamp_ms: 0,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for diagnostic in transcript.diagnostics {
|
|
||||||
events.push(diagnostic_event(format!(
|
|
||||||
"Backend transcript diagnostic [{}]: {}",
|
|
||||||
diagnostic.code, diagnostic.message
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn observe_worker_events(
|
|
||||||
target: BackendRuntimeTarget,
|
|
||||||
tx: mpsc::UnboundedSender<Event>,
|
|
||||||
mut suppress_next_snapshot: bool,
|
|
||||||
) {
|
|
||||||
let mut cursor: Option<String> = None;
|
|
||||||
let mut last_sequence = 0_u64;
|
|
||||||
let mut attempts = 0_usize;
|
let mut attempts = 0_usize;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let url = observation_ws_url(&target, cursor.as_deref());
|
let url = observation_ws_url(&target);
|
||||||
match connect_async(&url).await {
|
match connect_async(&url).await {
|
||||||
Ok((mut ws, _)) => {
|
Ok((mut ws, _)) => {
|
||||||
attempts = 0;
|
attempts = 0;
|
||||||
|
|
@ -390,25 +293,6 @@ async fn observe_worker_events(
|
||||||
)));
|
)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(sequence) = decode_backend_cursor(&envelope.cursor)
|
|
||||||
{
|
|
||||||
if sequence <= last_sequence {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
last_sequence = sequence;
|
|
||||||
} else {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend observation cursor was malformed: {}",
|
|
||||||
envelope.cursor
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
cursor = Some(envelope.cursor.clone());
|
|
||||||
if suppress_next_snapshot
|
|
||||||
&& matches!(envelope.payload, Event::Snapshot { .. })
|
|
||||||
{
|
|
||||||
suppress_next_snapshot = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let _ = tx.send(envelope.payload);
|
let _ = tx.send(envelope.payload);
|
||||||
}
|
}
|
||||||
Ok(ClientWorkerEventWsFrame::Diagnostic { diagnostic }) => {
|
Ok(ClientWorkerEventWsFrame::Diagnostic { diagnostic }) => {
|
||||||
|
|
@ -417,11 +301,6 @@ async fn observe_worker_events(
|
||||||
diagnostic.code, diagnostic.message
|
diagnostic.code, diagnostic.message
|
||||||
);
|
);
|
||||||
let _ = tx.send(diagnostic_event(message));
|
let _ = tx.send(diagnostic_event(message));
|
||||||
if diagnostic.code == "backend.cursor_unknown_or_expired" {
|
|
||||||
cursor = None;
|
|
||||||
last_sequence = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
|
@ -496,18 +375,13 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn observation_ws_url(target: &BackendRuntimeTarget, cursor: Option<&str>) -> String {
|
fn observation_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/runtimes/{}/workers/{}/events/ws",
|
"/api/runtimes/{}/workers/{}/events/ws",
|
||||||
path_segment_encode(&target.runtime_id),
|
path_segment_encode(&target.runtime_id),
|
||||||
path_segment_encode(&target.worker_id)
|
path_segment_encode(&target.worker_id)
|
||||||
);
|
);
|
||||||
let mut url = join_base_and_path(&http_base_to_ws(&target.base_url), &path);
|
join_base_and_path(&http_base_to_ws(&target.base_url), &path)
|
||||||
if let Some(cursor) = cursor {
|
|
||||||
url.push_str("?cursor=");
|
|
||||||
url.push_str(&query_value_encode(cursor));
|
|
||||||
}
|
|
||||||
url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn http_base_to_ws(base: &str) -> String {
|
fn http_base_to_ws(base: &str) -> String {
|
||||||
|
|
@ -524,26 +398,12 @@ fn join_base_and_path(base: &str, path: &str) -> String {
|
||||||
format!("{}{}", base.trim_end_matches('/'), path)
|
format!("{}{}", base.trim_end_matches('/'), path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_backend_cursor(cursor: &str) -> Option<u64> {
|
|
||||||
let encoded = cursor.strip_prefix("bo_")?;
|
|
||||||
if encoded.len() != 16 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
u64::from_str_radix(encoded, 16).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_segment_encode(input: &str) -> String {
|
fn path_segment_encode(input: &str) -> String {
|
||||||
percent_encode(input, |byte| {
|
percent_encode(input, |byte| {
|
||||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
|
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn query_value_encode(input: &str) -> String {
|
|
||||||
percent_encode(input, |byte| {
|
|
||||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
||||||
let mut encoded = String::with_capacity(input.len());
|
let mut encoded = String::with_capacity(input.len());
|
||||||
for byte in input.bytes() {
|
for byte in input.bytes() {
|
||||||
|
|
@ -595,20 +455,6 @@ struct BackendDiagnostic {
|
||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct WorkerTranscriptProjection {
|
|
||||||
#[serde(default)]
|
|
||||||
items: Vec<WorkerTranscriptItem>,
|
|
||||||
#[serde(default)]
|
|
||||||
diagnostics: Vec<BackendDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct WorkerTranscriptItem {
|
|
||||||
role: String,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
enum ClientWorkerEventWsFrame {
|
enum ClientWorkerEventWsFrame {
|
||||||
|
|
@ -622,7 +468,6 @@ enum ClientWorkerEventWsFrame {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ClientWorkerEventWsEnvelope {
|
struct ClientWorkerEventWsEnvelope {
|
||||||
cursor: String,
|
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
worker_id: String,
|
worker_id: String,
|
||||||
payload: Event,
|
payload: Event,
|
||||||
|
|
@ -662,33 +507,8 @@ mod tests {
|
||||||
let target =
|
let target =
|
||||||
BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one");
|
BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
observation_ws_url(&target, Some("bo_0000000000000001")),
|
observation_ws_url(&target),
|
||||||
"ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/events/ws?cursor=bo_0000000000000001"
|
"ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/events/ws"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transcript_projection_seeds_snapshot_and_chat_events() {
|
|
||||||
let target = BackendRuntimeTarget::new("http://backend", "runtime-a", "worker-b");
|
|
||||||
let events = transcript_projection_to_events(
|
|
||||||
&target,
|
|
||||||
WorkerTranscriptProjection {
|
|
||||||
items: vec![
|
|
||||||
WorkerTranscriptItem {
|
|
||||||
role: "user".to_string(),
|
|
||||||
content: "hi".to_string(),
|
|
||||||
},
|
|
||||||
WorkerTranscriptItem {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: "hello".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
diagnostics: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert!(matches!(events[0], Event::Snapshot { .. }));
|
|
||||||
assert!(matches!(events[1], Event::UserMessage { .. }));
|
|
||||||
assert!(matches!(events[2], Event::TextDelta { .. }));
|
|
||||||
assert!(matches!(events[3], Event::TextDone { .. }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1227,7 +1227,7 @@ workflow = "ticket-review-workflow"
|
||||||
temp.path(),
|
temp.path(),
|
||||||
r#"
|
r#"
|
||||||
[roles.coder]
|
[roles.coder]
|
||||||
profile = "./coder.lua"
|
profile = "./coder.toml"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ license.workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
llm-engine = { workspace = true }
|
llm-engine = { workspace = true }
|
||||||
mlua = { version = "0.11.4", features = ["lua54", "vendored", "serialize"] }
|
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1193,9 +1193,9 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
let toml = MINIMAL_REQUIRED.replace(
|
let toml = MINIMAL_REQUIRED.replace(
|
||||||
"[engine]\n",
|
"[engine]\n",
|
||||||
"[engine]\n\
|
"[engine]\n\
|
||||||
[worker.tool_output]\n\
|
[engine.tool_output]\n\
|
||||||
default_max_bytes = 8192\n\n\
|
default_max_bytes = 8192\n\n\
|
||||||
[worker.tool_output.per_tool]\n\
|
[engine.tool_output.per_tool]\n\
|
||||||
Read = 32768\n\
|
Read = 32768\n\
|
||||||
Grep = 4096\n\n\
|
Grep = 4096\n\n\
|
||||||
[engine.file_upload]\n\
|
[engine.file_upload]\n\
|
||||||
|
|
@ -1215,7 +1215,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||||
let toml = MINIMAL_REQUIRED.replace(
|
let toml = MINIMAL_REQUIRED.replace(
|
||||||
"[engine]\n",
|
"[engine]\n",
|
||||||
"[engine]\n\
|
"[engine]\n\
|
||||||
[worker.tool_output]\n",
|
[engine.tool_output]\n",
|
||||||
);
|
);
|
||||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
let limits = &manifest.engine.tool_output;
|
let limits = &manifest.engine.tool_output;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -100,8 +100,13 @@ pub struct WorkerMetadata {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub active: Option<WorkerActiveSegmentRef>,
|
pub active: Option<WorkerActiveSegmentRef>,
|
||||||
|
/// Legacy local path hint retained for host/runtime compatibility. It is not
|
||||||
|
/// Worker workspace identity or authority.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub workspace_root: Option<PathBuf>,
|
pub workspace_root: Option<PathBuf>,
|
||||||
|
/// Path-free workspace identity supplied by the host/runtime boundary.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub spawned_children: Vec<WorkerSpawnedChild>,
|
pub spawned_children: Vec<WorkerSpawnedChild>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
|
@ -119,6 +124,7 @@ impl WorkerMetadata {
|
||||||
worker_name: worker_name.into(),
|
worker_name: worker_name.into(),
|
||||||
active,
|
active,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -130,6 +136,11 @@ impl WorkerMetadata {
|
||||||
self.workspace_root = Some(workspace_root);
|
self.workspace_root = Some(workspace_root);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
|
||||||
|
self.workspace_id = Some(workspace_id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync persistence backend for Worker metadata.
|
/// Sync persistence backend for Worker metadata.
|
||||||
|
|
@ -180,6 +191,44 @@ pub trait WorkerMetadataStore: Send + Sync {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the active pointer and workspace ownership while preserving unrelated fields.
|
/// Set the active pointer and workspace ownership while preserving unrelated fields.
|
||||||
|
fn set_active_with_workspace_context(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
active: Option<WorkerActiveSegmentRef>,
|
||||||
|
resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
workspace_root: Option<PathBuf>,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
|
self.update_by_name(worker_name, |metadata| {
|
||||||
|
metadata.active = active;
|
||||||
|
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
||||||
|
if let Some(workspace_id) = workspace_id {
|
||||||
|
metadata.workspace_id = Some(workspace_id);
|
||||||
|
}
|
||||||
|
if let Some(workspace_root) = workspace_root {
|
||||||
|
metadata.workspace_root = Some(workspace_root);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the active pointer and path-free workspace identity while preserving unrelated fields.
|
||||||
|
fn set_active_with_workspace_id(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
active: Option<WorkerActiveSegmentRef>,
|
||||||
|
resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError> {
|
||||||
|
self.update_by_name(worker_name, |metadata| {
|
||||||
|
metadata.active = active;
|
||||||
|
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
|
||||||
|
if let Some(workspace_id) = workspace_id {
|
||||||
|
metadata.workspace_id = Some(workspace_id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the active pointer and legacy local workspace-root hint while preserving unrelated fields.
|
||||||
fn set_active_with_workspace_root(
|
fn set_active_with_workspace_root(
|
||||||
&self,
|
&self,
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
|
|
@ -497,7 +546,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
|
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
|
||||||
"worker": { "name": "profile-worker" },
|
"worker": { "name": "profile-worker" },
|
||||||
"profile": { "source": { "kind": "path", "path": "/profiles/coder.lua" } }
|
"profile": { "source": { "kind": "path", "path": "/profiles/coder.toml" } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let json = serde_json::to_string(&metadata).unwrap();
|
let json = serde_json::to_string(&metadata).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -526,7 +526,9 @@ impl ProfileSelectorRef {
|
||||||
if value.starts_with("path:")
|
if value.starts_with("path:")
|
||||||
|| value.starts_with('.')
|
|| value.starts_with('.')
|
||||||
|| value.contains('/')
|
|| value.contains('/')
|
||||||
|| value.ends_with(".lua")
|
|| value.ends_with(".dcdl")
|
||||||
|
|| value.ends_with(".json")
|
||||||
|
|| value.ends_with(".toml")
|
||||||
|| value.ends_with(".nix")
|
|| value.ends_with(".nix")
|
||||||
{
|
{
|
||||||
return Err("profile selector must be `inherit`, `default`, a source-qualified registry selector, or an unqualified registry selector; path selectors are not supported".to_string());
|
return Err("profile selector must be `inherit`, `default`, a source-qualified registry selector, or an unqualified registry selector; path selectors are not supported".to_string());
|
||||||
|
|
@ -1347,7 +1349,7 @@ profile = "legacy.nix"
|
||||||
temp.path(),
|
temp.path(),
|
||||||
r#"
|
r#"
|
||||||
[roles.coder]
|
[roles.coder]
|
||||||
profile = "./coder.lua"
|
profile = "./coder.toml"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // worker lifetime
|
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // worker lifetime
|
||||||
//! let tracker = Tracker::new(); // session lifetime
|
//! let tracker = Tracker::new(); // session lifetime
|
||||||
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
|
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
|
||||||
//! let defs = core_builtin_tools(fs, tracker, bash_outputs, None);
|
//! let defs = core_builtin_tools(fs, tracker, bash_outputs);
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use toml::Value;
|
||||||
use toml::map::Map;
|
use toml::map::Map;
|
||||||
|
|
||||||
const GENERATED_PROFILE_NAME: &str = "default";
|
const GENERATED_PROFILE_NAME: &str = "default";
|
||||||
const GENERATED_PROFILE_PATH: &str = "profiles/default.lua";
|
const GENERATED_PROFILE_PATH: &str = "profiles/default.toml";
|
||||||
const GENERATED_PROFILE_DESCRIPTION: &str = "Generated by yoi setup-model";
|
const GENERATED_PROFILE_DESCRIPTION: &str = "Generated by yoi setup-model";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -149,7 +149,7 @@ pub fn write_default_profile_config(
|
||||||
std::fs::create_dir_all(&profiles_dir)?;
|
std::fs::create_dir_all(&profiles_dir)?;
|
||||||
|
|
||||||
let profile_path = config_dir.join(GENERATED_PROFILE_PATH);
|
let profile_path = config_dir.join(GENERATED_PROFILE_PATH);
|
||||||
std::fs::write(&profile_path, generated_profile_lua(model_ref))?;
|
std::fs::write(&profile_path, generated_profile_toml(model_ref))?;
|
||||||
|
|
||||||
let registry_path = config_dir.join("profiles.toml");
|
let registry_path = config_dir.join("profiles.toml");
|
||||||
let mut document = read_registry_document(®istry_path)?;
|
let mut document = read_registry_document(®istry_path)?;
|
||||||
|
|
@ -203,65 +203,63 @@ fn set_default_profile_entry(document: &mut Value) -> Result<(), Box<dyn std::er
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generated_profile_lua(model_ref: &str) -> String {
|
fn generated_profile_toml(model_ref: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"local profile = require("yoi.profile")
|
r#"slug = "default"
|
||||||
local scope = require("yoi.scope")
|
description = "Generated by yoi setup-model"
|
||||||
local compact = require("yoi.compact")
|
scope = "workspace_write"
|
||||||
|
|
||||||
return profile {{
|
[model]
|
||||||
slug = "default",
|
ref = "{}"
|
||||||
description = "Generated by yoi setup-model",
|
|
||||||
|
|
||||||
scope = scope.workspace_write(),
|
[session]
|
||||||
|
record_event_trace = true
|
||||||
|
|
||||||
session = {{
|
[engine]
|
||||||
record_event_trace = true,
|
reasoning = "high"
|
||||||
}},
|
|
||||||
|
|
||||||
worker = {{
|
[compaction]
|
||||||
reasoning = "high",
|
kind = "tokens"
|
||||||
}},
|
threshold = 240000
|
||||||
|
request_threshold = 270000
|
||||||
|
worker_context_max_tokens = 100000
|
||||||
|
|
||||||
model = {{
|
[feature.task]
|
||||||
ref = "{}",
|
enabled = true
|
||||||
}},
|
|
||||||
|
|
||||||
compaction = compact.tokens {{
|
[feature.memory]
|
||||||
threshold = 240000,
|
enabled = true
|
||||||
request_threshold = 270000,
|
|
||||||
worker_context_max_tokens = 100000,
|
|
||||||
}},
|
|
||||||
|
|
||||||
feature = {{
|
[feature.web]
|
||||||
task = {{ enabled = true }},
|
enabled = true
|
||||||
memory = {{ enabled = true }},
|
|
||||||
web = {{ enabled = true }},
|
|
||||||
workers = {{ enabled = false }},
|
|
||||||
ticket = {{ enabled = false, access = "lifecycle" }},
|
|
||||||
ticket_orchestration = {{ enabled = false }},
|
|
||||||
}},
|
|
||||||
|
|
||||||
memory = {{
|
[feature.workers]
|
||||||
extract_threshold = 50000,
|
enabled = false
|
||||||
consolidation_threshold_files = 5,
|
|
||||||
consolidation_threshold_bytes = 50000,
|
|
||||||
}},
|
|
||||||
|
|
||||||
web = {{
|
[feature.ticket]
|
||||||
enabled = true,
|
enabled = false
|
||||||
search = {{
|
access = "lifecycle"
|
||||||
provider = "brave",
|
|
||||||
api_key_secret = "web/brave/default",
|
[feature.ticket_orchestration]
|
||||||
}},
|
enabled = false
|
||||||
}},
|
|
||||||
}}
|
[memory]
|
||||||
|
extract_threshold = 50000
|
||||||
|
consolidation_threshold_files = 5
|
||||||
|
consolidation_threshold_bytes = 50000
|
||||||
|
|
||||||
|
[web]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[web.search]
|
||||||
|
provider = "brave"
|
||||||
|
api_key_secret = "web/brave/default"
|
||||||
"#,
|
"#,
|
||||||
escape_lua_string(model_ref)
|
escape_toml_string(model_ref)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_lua_string(value: &str) -> String {
|
fn escape_toml_string(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.chars()
|
.chars()
|
||||||
.flat_map(|c| match c {
|
.flat_map(|c| match c {
|
||||||
|
|
@ -288,18 +286,18 @@ mod tests {
|
||||||
assert_eq!(written.registry_path, dir.path().join("profiles.toml"));
|
assert_eq!(written.registry_path, dir.path().join("profiles.toml"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
written.profile_path,
|
written.profile_path,
|
||||||
dir.path().join("profiles/default.lua")
|
dir.path().join("profiles/default.toml")
|
||||||
);
|
);
|
||||||
|
|
||||||
let registry = std::fs::read_to_string(&written.registry_path).unwrap();
|
let registry = std::fs::read_to_string(&written.registry_path).unwrap();
|
||||||
assert!(registry.contains("default = \"user:default\""));
|
assert!(registry.contains("default = \"user:default\""));
|
||||||
assert!(registry.contains("[profile.default]"));
|
assert!(registry.contains("[profile.default]"));
|
||||||
assert!(registry.contains("path = \"profiles/default.lua\""));
|
assert!(registry.contains("path = \"profiles/default.toml\""));
|
||||||
|
|
||||||
let profile = std::fs::read_to_string(&written.profile_path).unwrap();
|
let profile = std::fs::read_to_string(&written.profile_path).unwrap();
|
||||||
assert!(profile.contains("slug = \"default\""));
|
assert!(profile.contains("slug = \"default\""));
|
||||||
assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\""));
|
assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\""));
|
||||||
assert!(profile.contains("scope = scope.workspace_write()"));
|
assert!(profile.contains("scope = \"workspace_write\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -308,7 +306,7 @@ mod tests {
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
dir.path().join("profiles.toml"),
|
dir.path().join("profiles.toml"),
|
||||||
r#"[profile.other]
|
r#"[profile.other]
|
||||||
path = "profiles/other.lua"
|
path = "profiles/other.toml"
|
||||||
description = "keep me"
|
description = "keep me"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -318,13 +316,13 @@ description = "keep me"
|
||||||
|
|
||||||
let registry = std::fs::read_to_string(dir.path().join("profiles.toml")).unwrap();
|
let registry = std::fs::read_to_string(dir.path().join("profiles.toml")).unwrap();
|
||||||
assert!(registry.contains("[profile.other]"));
|
assert!(registry.contains("[profile.other]"));
|
||||||
assert!(registry.contains("path = \"profiles/other.lua\""));
|
assert!(registry.contains("path = \"profiles/other.toml\""));
|
||||||
assert!(registry.contains("[profile.default]"));
|
assert!(registry.contains("[profile.default]"));
|
||||||
assert!(registry.contains("default = \"user:default\""));
|
assert!(registry.contains("default = \"user:default\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_lua_string_escapes_quotes_and_slashes() {
|
fn escape_toml_string_escapes_quotes_and_slashes() {
|
||||||
assert_eq!(escape_lua_string("a\\b\"c"), "a\\\\b\\\"c");
|
assert_eq!(escape_toml_string("a\\b\"c"), "a\\\\b\\\"c");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,7 @@ mod tests {
|
||||||
r#"
|
r#"
|
||||||
default = "coder"
|
default = "coder"
|
||||||
[profile]
|
[profile]
|
||||||
coder = "profiles/coder.lua"
|
coder = "profiles/coder.toml"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -692,7 +692,7 @@ coder = "profiles/coder.lua"
|
||||||
r#"
|
r#"
|
||||||
default = "coder"
|
default = "coder"
|
||||||
[profile.coder]
|
[profile.coder]
|
||||||
path = "profiles/coder.lua"
|
path = "profiles/coder.toml"
|
||||||
description = "Project coder"
|
description = "Project coder"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
|
||||||
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", "rt", "sync", "time"] }
|
||||||
|
toml.workspace = true
|
||||||
tower = { workspace = true, features = ["util"], optional = true }
|
tower = { workspace = true, features = ["util"], optional = true }
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use crate::execution::WorkerExecutionStatus;
|
use crate::execution::WorkerExecutionStatus;
|
||||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
||||||
use crate::interaction::WorkerInput;
|
use crate::interaction::WorkerInput;
|
||||||
|
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Profile selector boundary. This is a selector, not a resolved config bundle.
|
/// Profile selector boundary. This is a selector, not a resolved runtime config.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||||
pub enum ProfileSelector {
|
pub enum ProfileSelector {
|
||||||
|
|
@ -19,7 +20,36 @@ impl Default for ProfileSelector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backend-synced config bundle reference used during Worker creation.
|
/// Runtime fetch/caching metadata for a Backend-authored Decodal profile source archive.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileSourceArchiveHttpRef {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub etag: Option<String>,
|
||||||
|
pub archive: ProfileSourceArchiveRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profile source material available to a Runtime during Worker creation.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ProfileSourceArchiveSource {
|
||||||
|
/// Backend-internal embedded runtimes may receive already-built archive bytes.
|
||||||
|
Embedded { archive: ProfileSourceArchive },
|
||||||
|
/// Standalone runtimes fetch/cache the tar archive over HTTP.
|
||||||
|
Http {
|
||||||
|
location: ProfileSourceArchiveHttpRef,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileSourceArchiveSource {
|
||||||
|
pub fn reference(&self) -> ProfileSourceArchiveRef {
|
||||||
|
match self {
|
||||||
|
Self::Embedded { archive } => archive.reference.clone(),
|
||||||
|
Self::Http { location } => location.archive.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ConfigBundleRef {
|
pub struct ConfigBundleRef {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -87,6 +117,10 @@ pub struct WorkingDirectoryRequest {
|
||||||
pub materializer: MaterializerKind,
|
pub materializer: MaterializerKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
pub dirty_state_policy: DirtyStatePolicy,
|
||||||
|
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
|
||||||
|
/// Backend can create canonical registry rows before materialization.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub backend_workdir_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -128,6 +162,10 @@ pub struct WorkingDirectorySummary {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cleanup_policy: Option<String>,
|
pub cleanup_policy: Option<String>,
|
||||||
pub status: WorkingDirectoryStatusKind,
|
pub status: WorkingDirectoryStatusKind,
|
||||||
|
/// Backend projection metadata. Runtimes leave this absent; Workspace Browser
|
||||||
|
/// APIs fill it with `backend_managed` or `runtime_unmanaged`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub management_kind: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -139,16 +177,17 @@ pub struct WorkingDirectoryStatus {
|
||||||
///
|
///
|
||||||
/// Browser/product launch semantics are resolved by a backend before this
|
/// Browser/product launch semantics are resolved by a backend before this
|
||||||
/// request is built. The request contains only durable Runtime identity inputs:
|
/// request is built. The request contains only durable Runtime identity inputs:
|
||||||
/// a backend-decided profile selector, a previously synced ConfigBundle identity,
|
/// a backend-decided profile selector, the Decodal profile source archive source
|
||||||
/// optional initial user input that is committed in the same transaction as
|
/// used to resolve that selector, optional initial user input committed as a
|
||||||
/// Worker catalog/transcript persistence, and an optional working directory
|
/// protocol observation event, and an optional Runtime-owned working directory
|
||||||
/// request that preserves RepositoryPoint-style semantics for runtime-side
|
/// binding. Browser-facing status for materialized working directories is
|
||||||
/// materialization. Browser-facing status for materialized working directories is
|
|
||||||
/// summarized without exposing raw host paths.
|
/// summarized without exposing raw host paths.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CreateWorkerRequest {
|
pub struct CreateWorkerRequest {
|
||||||
pub profile: ProfileSelector,
|
pub profile: ProfileSelector,
|
||||||
pub config_bundle: ConfigBundleRef,
|
pub profile_source: ProfileSourceArchiveSource,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_bundle: Option<ConfigBundleRef>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub initial_input: Option<WorkerInput>,
|
pub initial_input: Option<WorkerInput>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -181,8 +220,9 @@ pub struct WorkerSummary {
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
pub execution: WorkerExecutionStatus,
|
pub execution: WorkerExecutionStatus,
|
||||||
pub profile: ProfileSelector,
|
pub profile: ProfileSelector,
|
||||||
pub config_bundle: ConfigBundleRef,
|
pub profile_source: ProfileSourceArchiveRef,
|
||||||
pub transcript_len: usize,
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_bundle: Option<ConfigBundleRef>,
|
||||||
pub last_event_id: u64,
|
pub last_event_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,8 +235,9 @@ pub struct WorkerDetail {
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
pub execution: WorkerExecutionStatus,
|
pub execution: WorkerExecutionStatus,
|
||||||
pub profile: ProfileSelector,
|
pub profile: ProfileSelector,
|
||||||
pub config_bundle: ConfigBundleRef,
|
pub profile_source: ProfileSourceArchiveRef,
|
||||||
pub transcript_len: usize,
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_bundle: Option<ConfigBundleRef>,
|
||||||
pub last_event_id: u64,
|
pub last_event_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryRequest, WorkingDirectoryStatus};
|
||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::WorkerRef;
|
use crate::identity::WorkerRef;
|
||||||
use crate::interaction::WorkerInput;
|
use crate::interaction::WorkerInput;
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::WorkerObservationEvent;
|
use crate::observation::WorkerObservationEvent;
|
||||||
use crate::working_directory::WorkingDirectoryBinding;
|
use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryDiagnostic};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -305,7 +305,7 @@ pub enum WorkerExecutionSpawnResult {
|
||||||
|
|
||||||
/// Backend boundary for Worker execution.
|
/// Backend boundary for Worker execution.
|
||||||
///
|
///
|
||||||
/// Runtime owns Worker catalog, transcript, observation, and lifecycle state. A
|
/// Runtime owns Worker catalog, protocol observation, and lifecycle state. A
|
||||||
/// backend owns concrete execution. The default Runtime has no backend, so input
|
/// backend owns concrete execution. The default Runtime has no backend, so input
|
||||||
/// to those Workers is rejected instead of producing providerless responses.
|
/// to those Workers is rejected instead of producing providerless responses.
|
||||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||||
|
|
@ -313,6 +313,42 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
||||||
|
|
||||||
|
fn create_working_directory(
|
||||||
|
&self,
|
||||||
|
_request: &WorkingDirectoryRequest,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_unsupported",
|
||||||
|
"Worker execution backend does not support working directory materialization",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_unknown",
|
||||||
|
format!("working directory `{working_directory_id}` is not known to this Runtime"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_cleanup_unsupported",
|
||||||
|
format!(
|
||||||
|
"working directory `{working_directory_id}` cannot be cleaned up by this backend"
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_input(
|
fn dispatch_input(
|
||||||
&self,
|
&self,
|
||||||
handle: &WorkerExecutionHandle,
|
handle: &WorkerExecutionHandle,
|
||||||
|
|
@ -358,6 +394,31 @@ impl WorkerExecutionBackendRef {
|
||||||
self.backend.spawn_worker(request)
|
self.backend.spawn_worker(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn create_working_directory(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRequest,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
self.backend.create_working_directory(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
|
self.backend.list_working_directories()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
self.backend.working_directory(working_directory_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cleanup_working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
self.backend.cleanup_working_directory(working_directory_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn dispatch_input(
|
pub(crate) fn dispatch_input(
|
||||||
&self,
|
&self,
|
||||||
handle: &WorkerExecutionHandle,
|
handle: &WorkerExecutionHandle,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
use crate::catalog::{CreateWorkerRequest, WorkerStatus};
|
use crate::catalog::{CreateWorkerRequest, WorkerStatus};
|
||||||
use crate::config_bundle::ConfigBundle;
|
use crate::config_bundle::ConfigBundle;
|
||||||
use crate::diagnostics::RuntimeDiagnostic;
|
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::execution::WorkerExecutionStatus;
|
use crate::execution::WorkerExecutionStatus;
|
||||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
||||||
use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus};
|
use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus};
|
||||||
use crate::observation::{
|
#[cfg(feature = "ws-server")]
|
||||||
EventCursor, RuntimeEvent, RuntimeEventBatch, TranscriptEntry, TranscriptProjection,
|
use crate::observation::WorkerObservationEvent;
|
||||||
TranscriptQuery,
|
use crate::observation::{EventCursor, RuntimeEvent, RuntimeEventBatch};
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
|
@ -22,7 +21,8 @@ const RUNTIME_FILE: &str = "runtime.json";
|
||||||
const EVENTS_FILE: &str = "events.jsonl";
|
const EVENTS_FILE: &str = "events.jsonl";
|
||||||
const WORKERS_DIR: &str = "workers";
|
const WORKERS_DIR: &str = "workers";
|
||||||
const WORKER_FILE: &str = "worker.json";
|
const WORKER_FILE: &str = "worker.json";
|
||||||
const TRANSCRIPT_FILE: &str = "transcript.jsonl";
|
#[cfg(feature = "ws-server")]
|
||||||
|
const OBSERVATIONS_FILE: &str = "observations.jsonl";
|
||||||
|
|
||||||
static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
|
@ -119,42 +119,6 @@ impl FsRuntimeStore {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a persisted Worker transcript directly from its Worker-scoped log.
|
|
||||||
pub fn read_transcript(
|
|
||||||
&self,
|
|
||||||
worker_ref: &WorkerRef,
|
|
||||||
query: TranscriptQuery,
|
|
||||||
max_limit: usize,
|
|
||||||
) -> Result<TranscriptProjection, RuntimeError> {
|
|
||||||
self.ensure_worker_ref(worker_ref)?;
|
|
||||||
if query.limit > max_limit {
|
|
||||||
return Err(RuntimeError::LimitTooLarge {
|
|
||||||
requested: query.limit,
|
|
||||||
max: max_limit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = self.transcript_path(&worker_ref.worker_id);
|
|
||||||
let entries = read_json_lines::<TranscriptEntry>(&path, "read transcript")?;
|
|
||||||
let total_items = entries.len();
|
|
||||||
let end = query.start.saturating_add(query.limit).min(total_items);
|
|
||||||
let items = if query.start >= total_items {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
entries[query.start..end].to_vec()
|
|
||||||
};
|
|
||||||
let next_start = (end < total_items).then_some(end);
|
|
||||||
|
|
||||||
Ok(TranscriptProjection {
|
|
||||||
worker_ref: worker_ref.clone(),
|
|
||||||
start: query.start,
|
|
||||||
limit: query.limit,
|
|
||||||
total_items,
|
|
||||||
items,
|
|
||||||
next_start,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn open_or_create(
|
pub(crate) fn open_or_create(
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
runtime_id: RuntimeId,
|
runtime_id: RuntimeId,
|
||||||
|
|
@ -220,7 +184,12 @@ impl FsRuntimeStore {
|
||||||
&WorkerSnapshot::from_persisted(worker),
|
&WorkerSnapshot::from_persisted(worker),
|
||||||
"write worker snapshot",
|
"write worker snapshot",
|
||||||
)?;
|
)?;
|
||||||
ensure_file_exists(&worker_dir.join(TRANSCRIPT_FILE), "create transcript log")
|
#[cfg(feature = "ws-server")]
|
||||||
|
ensure_file_exists(
|
||||||
|
&worker_dir.join(OBSERVATIONS_FILE),
|
||||||
|
"create observations log",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn append_event(&self, event: &RuntimeEvent) -> Result<(), RuntimeError> {
|
pub(crate) fn append_event(&self, event: &RuntimeEvent) -> Result<(), RuntimeError> {
|
||||||
|
|
@ -230,21 +199,22 @@ impl FsRuntimeStore {
|
||||||
append_json_line(&self.events_path(), event, "append event")
|
append_json_line(&self.events_path(), event, "append event")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn append_transcript_entry(
|
#[cfg(feature = "ws-server")]
|
||||||
|
pub(crate) fn append_worker_observation_event(
|
||||||
&self,
|
&self,
|
||||||
entry: &TranscriptEntry,
|
event: &WorkerObservationEvent,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
self.ensure_worker_ref(&entry.worker_ref)?;
|
self.ensure_worker_ref(&event.worker_ref)?;
|
||||||
append_json_line(
|
append_json_line(
|
||||||
&self.transcript_path(&entry.worker_ref.worker_id),
|
&self.observations_path(&event.worker_ref.worker_id),
|
||||||
entry,
|
event,
|
||||||
"append transcript",
|
"append worker observation",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_runtime_state(&self) -> Result<PersistedRuntimeState, RuntimeError> {
|
pub(crate) fn load_runtime_state(&self) -> Result<PersistedRuntimeState, RuntimeError> {
|
||||||
let runtime_path = self.runtime_path();
|
let runtime_path = self.runtime_path();
|
||||||
let snapshot: RuntimeSnapshot = read_json(&runtime_path, "read runtime snapshot")?;
|
let mut snapshot: RuntimeSnapshot = read_json(&runtime_path, "read runtime snapshot")?;
|
||||||
snapshot.validate(&self.runtime_id, &runtime_path)?;
|
snapshot.validate(&self.runtime_id, &runtime_path)?;
|
||||||
|
|
||||||
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
|
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
|
||||||
|
|
@ -278,45 +248,99 @@ impl FsRuntimeStore {
|
||||||
})?;
|
})?;
|
||||||
worker_dirs.sort_by_key(|entry| entry.path());
|
worker_dirs.sort_by_key(|entry| entry.path());
|
||||||
|
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
let mut observation_events = Vec::new();
|
||||||
|
|
||||||
for entry in worker_dirs {
|
for entry in worker_dirs {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
record_worker_load_diagnostic(
|
||||||
operation: "read worker",
|
&mut snapshot,
|
||||||
path,
|
None,
|
||||||
message: "worker path exists but is not a directory".to_string(),
|
"ignored invalid worker store entry while loading runtime store",
|
||||||
});
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let worker_snapshot_path = path.join(WORKER_FILE);
|
let worker_snapshot_path = path.join(WORKER_FILE);
|
||||||
let worker_snapshot: WorkerSnapshot =
|
let worker_snapshot: WorkerSnapshot =
|
||||||
read_json(&worker_snapshot_path, "read worker snapshot")?;
|
match read_json(&worker_snapshot_path, "read worker snapshot") {
|
||||||
worker_snapshot.validate(&self.runtime_id, &worker_snapshot_path)?;
|
Ok(snapshot) => snapshot,
|
||||||
let transcript =
|
Err(_error) => {
|
||||||
read_json_lines::<TranscriptEntry>(&path.join(TRANSCRIPT_FILE), "read transcript")?;
|
record_worker_load_diagnostic(
|
||||||
for entry in &transcript {
|
&mut snapshot,
|
||||||
self.ensure_worker_ref(&entry.worker_ref)?;
|
None,
|
||||||
if entry.worker_ref.worker_id != worker_snapshot.worker_id {
|
"ignored corrupt worker snapshot while loading runtime store",
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
);
|
||||||
operation: "read transcript",
|
continue;
|
||||||
path: path.join(TRANSCRIPT_FILE),
|
}
|
||||||
message: format!(
|
};
|
||||||
"transcript entry belongs to worker {}, expected {}",
|
if worker_snapshot
|
||||||
entry.worker_ref.worker_id, worker_snapshot.worker_id
|
.validate(&self.runtime_id, &worker_snapshot_path)
|
||||||
),
|
.is_err()
|
||||||
});
|
{
|
||||||
|
record_worker_load_diagnostic(
|
||||||
|
&mut snapshot,
|
||||||
|
Some(worker_snapshot.worker_ref.clone()),
|
||||||
|
"ignored invalid worker snapshot while loading runtime store",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
let worker_observations = match read_json_lines::<WorkerObservationEvent>(
|
||||||
|
&path.join(OBSERVATIONS_FILE),
|
||||||
|
"read worker observations",
|
||||||
|
) {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(_error) => {
|
||||||
|
record_worker_load_diagnostic(
|
||||||
|
&mut snapshot,
|
||||||
|
Some(worker_snapshot.worker_ref.clone()),
|
||||||
|
"ignored worker with unreadable observations while loading runtime store",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
let mut observations_valid = true;
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
for event in &worker_observations {
|
||||||
|
if self.ensure_worker_ref(&event.worker_ref).is_err()
|
||||||
|
|| event.worker_ref.worker_id != worker_snapshot.worker_id
|
||||||
|
{
|
||||||
|
observations_valid = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let worker = worker_snapshot.into_persisted(transcript);
|
#[cfg(feature = "ws-server")]
|
||||||
|
if !observations_valid {
|
||||||
|
record_worker_load_diagnostic(
|
||||||
|
&mut snapshot,
|
||||||
|
Some(worker_snapshot.worker_ref.clone()),
|
||||||
|
"ignored worker with invalid observations while loading runtime store",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
observation_events.extend(worker_observations);
|
||||||
|
let worker = worker_snapshot.into_persisted();
|
||||||
if workers.insert(worker.worker_id.clone(), worker).is_some() {
|
if workers.insert(worker.worker_id.clone(), worker).is_some() {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
record_worker_load_diagnostic(
|
||||||
operation: "read workers",
|
&mut snapshot,
|
||||||
path: workers_dir.clone(),
|
None,
|
||||||
message: "duplicate worker id in store".to_string(),
|
"ignored duplicate worker snapshot while loading runtime store",
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(snapshot.into_persisted(events, workers))
|
#[cfg(feature = "ws-server")]
|
||||||
|
observation_events.sort_by_key(|event| event.sequence);
|
||||||
|
|
||||||
|
Ok(snapshot.into_persisted(
|
||||||
|
events,
|
||||||
|
workers,
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
observation_events,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
|
fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
|
||||||
|
|
@ -345,8 +369,9 @@ impl FsRuntimeStore {
|
||||||
.join(encoded_component(worker_id.as_str()))
|
.join(encoded_component(worker_id.as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transcript_path(&self, worker_id: &WorkerId) -> PathBuf {
|
#[cfg(feature = "ws-server")]
|
||||||
self.worker_dir(worker_id).join(TRANSCRIPT_FILE)
|
fn observations_path(&self, worker_id: &WorkerId) -> PathBuf {
|
||||||
|
self.worker_dir(worker_id).join(OBSERVATIONS_FILE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,6 +393,8 @@ pub(crate) struct PersistedRuntimeState {
|
||||||
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
||||||
pub(crate) config_bundles: BTreeMap<String, ConfigBundle>,
|
pub(crate) config_bundles: BTreeMap<String, ConfigBundle>,
|
||||||
pub(crate) events: Vec<RuntimeEvent>,
|
pub(crate) events: Vec<RuntimeEvent>,
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
pub(crate) observation_events: Vec<WorkerObservationEvent>,
|
||||||
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,8 +405,6 @@ pub(crate) struct PersistedWorkerRecord {
|
||||||
pub(crate) status: WorkerStatus,
|
pub(crate) status: WorkerStatus,
|
||||||
pub(crate) request: CreateWorkerRequest,
|
pub(crate) request: CreateWorkerRequest,
|
||||||
pub(crate) execution: WorkerExecutionStatus,
|
pub(crate) execution: WorkerExecutionStatus,
|
||||||
pub(crate) transcript: Vec<TranscriptEntry>,
|
|
||||||
pub(crate) next_transcript_sequence: u64,
|
|
||||||
pub(crate) last_event_id: u64,
|
pub(crate) last_event_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,6 +424,22 @@ struct RuntimeSnapshot {
|
||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_worker_load_diagnostic(
|
||||||
|
snapshot: &mut RuntimeSnapshot,
|
||||||
|
worker_ref: Option<WorkerRef>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
) {
|
||||||
|
let id = snapshot.next_diagnostic_id;
|
||||||
|
snapshot.next_diagnostic_id = snapshot.next_diagnostic_id.saturating_add(1);
|
||||||
|
snapshot.diagnostics.push(RuntimeDiagnostic {
|
||||||
|
id,
|
||||||
|
worker_ref,
|
||||||
|
severity: DiagnosticSeverity::Warning,
|
||||||
|
code: "worker_snapshot_ignored".to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
impl RuntimeSnapshot {
|
impl RuntimeSnapshot {
|
||||||
fn from_persisted(state: &PersistedRuntimeState) -> Self {
|
fn from_persisted(state: &PersistedRuntimeState) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -451,6 +492,7 @@ impl RuntimeSnapshot {
|
||||||
self,
|
self,
|
||||||
events: Vec<RuntimeEvent>,
|
events: Vec<RuntimeEvent>,
|
||||||
workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
||||||
|
#[cfg(feature = "ws-server")] observation_events: Vec<WorkerObservationEvent>,
|
||||||
) -> PersistedRuntimeState {
|
) -> PersistedRuntimeState {
|
||||||
PersistedRuntimeState {
|
PersistedRuntimeState {
|
||||||
runtime_id: self.runtime_id,
|
runtime_id: self.runtime_id,
|
||||||
|
|
@ -463,6 +505,8 @@ impl RuntimeSnapshot {
|
||||||
workers,
|
workers,
|
||||||
config_bundles: self.config_bundles,
|
config_bundles: self.config_bundles,
|
||||||
events,
|
events,
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
observation_events,
|
||||||
diagnostics: self.diagnostics,
|
diagnostics: self.diagnostics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -477,7 +521,6 @@ struct WorkerSnapshot {
|
||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
#[serde(default = "WorkerExecutionStatus::unconnected")]
|
#[serde(default = "WorkerExecutionStatus::unconnected")]
|
||||||
execution: WorkerExecutionStatus,
|
execution: WorkerExecutionStatus,
|
||||||
next_transcript_sequence: u64,
|
|
||||||
last_event_id: u64,
|
last_event_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,7 +533,6 @@ impl WorkerSnapshot {
|
||||||
status: worker.status,
|
status: worker.status,
|
||||||
request: worker.request.clone(),
|
request: worker.request.clone(),
|
||||||
execution: worker.execution.clone(),
|
execution: worker.execution.clone(),
|
||||||
next_transcript_sequence: worker.next_transcript_sequence,
|
|
||||||
last_event_id: worker.last_event_id,
|
last_event_id: worker.last_event_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -529,15 +571,13 @@ impl WorkerSnapshot {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn into_persisted(self, transcript: Vec<TranscriptEntry>) -> PersistedWorkerRecord {
|
fn into_persisted(self) -> PersistedWorkerRecord {
|
||||||
PersistedWorkerRecord {
|
PersistedWorkerRecord {
|
||||||
worker_ref: self.worker_ref,
|
worker_ref: self.worker_ref,
|
||||||
worker_id: self.worker_id,
|
worker_id: self.worker_id,
|
||||||
status: self.status,
|
status: self.status,
|
||||||
request: self.request,
|
request: self.request,
|
||||||
execution: self.execution,
|
execution: self.execution,
|
||||||
transcript,
|
|
||||||
next_transcript_sequence: self.next_transcript_sequence,
|
|
||||||
last_event_id: self.last_event_id,
|
last_event_id: self.last_event_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use crate::Runtime;
|
use crate::Runtime;
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
|
||||||
|
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
|
|
@ -17,7 +18,6 @@ use crate::interaction::{WorkerInput, WorkerInteractionAck};
|
||||||
use crate::management::{RuntimeLimits, RuntimeSummary};
|
use crate::management::{RuntimeLimits, RuntimeSummary};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::WorkerObservationCursor;
|
use crate::observation::WorkerObservationCursor;
|
||||||
use crate::observation::{TranscriptProjection, TranscriptQuery};
|
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
use axum::extract::rejection::{JsonRejection, QueryRejection};
|
use axum::extract::rejection::{JsonRejection, QueryRejection};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
|
|
@ -138,15 +138,19 @@ pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Rou
|
||||||
"/v1/config-bundles/{bundle_id}/availability",
|
"/v1/config-bundles/{bundle_id}/availability",
|
||||||
get(check_config_bundle),
|
get(check_config_bundle),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/working-directories",
|
||||||
|
get(list_working_directories).post(create_working_directory),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/working-directories/{working_directory_id}",
|
||||||
|
get(get_working_directory).delete(cleanup_working_directory),
|
||||||
|
)
|
||||||
.route("/v1/workers", get(list_workers).post(create_worker))
|
.route("/v1/workers", get(list_workers).post(create_worker))
|
||||||
.route("/v1/workers/{worker_id}", get(get_worker))
|
.route("/v1/workers/{worker_id}", get(get_worker))
|
||||||
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
||||||
.route("/v1/workers/{worker_id}/stop", post(stop_worker))
|
.route("/v1/workers/{worker_id}/stop", post(stop_worker))
|
||||||
.route("/v1/workers/{worker_id}/cancel", post(cancel_worker))
|
.route("/v1/workers/{worker_id}/cancel", post(cancel_worker));
|
||||||
.route(
|
|
||||||
"/v1/workers/{worker_id}/transcript",
|
|
||||||
get(get_worker_transcript),
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
let router = router.route("/v1/workers/{worker_id}/events/ws", get(worker_events_ws));
|
let router = router.route("/v1/workers/{worker_id}/events/ws", get(worker_events_ws));
|
||||||
|
|
@ -197,6 +201,18 @@ pub struct RuntimeHttpWorkersResponse {
|
||||||
pub workers: Vec<WorkerSummary>,
|
pub workers: Vec<WorkerSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/working-directories` response.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHttpWorkingDirectoriesResponse {
|
||||||
|
pub working_directories: Vec<WorkingDirectoryStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Working directory response used by create/detail/delete endpoints.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHttpWorkingDirectoryResponse {
|
||||||
|
pub working_directory: WorkingDirectoryStatus,
|
||||||
|
}
|
||||||
|
|
||||||
/// Worker detail response used by create/detail endpoints.
|
/// Worker detail response used by create/detail endpoints.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeHttpWorkerResponse {
|
pub struct RuntimeHttpWorkerResponse {
|
||||||
|
|
@ -222,12 +238,6 @@ pub struct RuntimeHttpWorkerLifecycleResponse {
|
||||||
pub ack: WorkerLifecycleAck,
|
pub ack: WorkerLifecycleAck,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /v1/workers/{worker_id}/transcript` response.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RuntimeHttpTranscriptResponse {
|
|
||||||
pub transcript: TranscriptProjection,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Typed REST error response.
|
/// Typed REST error response.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeHttpErrorResponse {
|
pub struct RuntimeHttpErrorResponse {
|
||||||
|
|
@ -278,18 +288,6 @@ struct RuntimeWorkerEventsWsQuery {
|
||||||
cursor: Option<String>,
|
cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
struct RuntimeHttpTranscriptQuery {
|
|
||||||
#[serde(default)]
|
|
||||||
start: usize,
|
|
||||||
#[serde(default = "default_transcript_limit")]
|
|
||||||
limit: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_transcript_limit() -> usize {
|
|
||||||
256
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
impl RuntimeWorkerEventWsFrame {
|
impl RuntimeWorkerEventWsFrame {
|
||||||
fn event(
|
fn event(
|
||||||
|
|
@ -399,6 +397,58 @@ async fn list_workers(
|
||||||
Ok(Json(RuntimeHttpWorkersResponse { workers }))
|
Ok(Json(RuntimeHttpWorkersResponse { workers }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_working_directories(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
|
||||||
|
let working_directories = state
|
||||||
|
.runtime
|
||||||
|
.list_working_directories()
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpWorkingDirectoriesResponse {
|
||||||
|
working_directories,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_working_directory(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
||||||
|
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||||
|
let working_directory = state
|
||||||
|
.runtime
|
||||||
|
.create_working_directory(request)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||||
|
working_directory,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_working_directory(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
Path(working_directory_id): Path<String>,
|
||||||
|
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
||||||
|
let working_directory = state
|
||||||
|
.runtime
|
||||||
|
.working_directory(&working_directory_id)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||||
|
working_directory,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_working_directory(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
Path(working_directory_id): Path<String>,
|
||||||
|
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
|
||||||
|
let working_directory = state
|
||||||
|
.runtime
|
||||||
|
.cleanup_working_directory(&working_directory_id)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
|
||||||
|
working_directory,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_worker(
|
async fn get_worker(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
Path(worker_id): Path<String>,
|
Path(worker_id): Path<String>,
|
||||||
|
|
@ -641,20 +691,6 @@ async fn cancel_worker(
|
||||||
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
|
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_worker_transcript(
|
|
||||||
State(state): State<RuntimeHttpState>,
|
|
||||||
Path(worker_id): Path<String>,
|
|
||||||
query: Result<Query<RuntimeHttpTranscriptQuery>, QueryRejection>,
|
|
||||||
) -> RestResult<RuntimeHttpTranscriptResponse> {
|
|
||||||
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
|
||||||
let Query(query) = query.map_err(RuntimeHttpRestError::query_rejection)?;
|
|
||||||
let transcript = state
|
|
||||||
.runtime
|
|
||||||
.transcript_projection(&worker_ref, TranscriptQuery::new(query.start, query.limit))
|
|
||||||
.map_err(RuntimeHttpRestError::runtime)?;
|
|
||||||
Ok(Json(RuntimeHttpTranscriptResponse { transcript }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn worker_ref_for(runtime: &Runtime, worker_id: String) -> Result<WorkerRef, RuntimeHttpRestError> {
|
fn worker_ref_for(runtime: &Runtime, worker_id: String) -> Result<WorkerRef, RuntimeHttpRestError> {
|
||||||
let worker_id = WorkerId::new(worker_id).ok_or_else(|| {
|
let worker_id = WorkerId::new(worker_id).ok_or_else(|| {
|
||||||
RuntimeHttpRestError::new(
|
RuntimeHttpRestError::new(
|
||||||
|
|
@ -860,10 +896,27 @@ mod tests {
|
||||||
let bundle = test_bundle(profile.clone());
|
let bundle = test_bundle(profile.clone());
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
profile,
|
profile,
|
||||||
config_bundle: ConfigBundleRef {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
||||||
|
location: crate::catalog::ProfileSourceArchiveHttpRef {
|
||||||
|
url: "http://127.0.0.1/profile-source.tar".to_string(),
|
||||||
|
etag: None,
|
||||||
|
archive: crate::profile_archive::ProfileSourceArchiveRef {
|
||||||
|
id: "test-profile-source".to_string(),
|
||||||
|
digest: "test-digest".to_string(),
|
||||||
|
size_bytes: 0,
|
||||||
|
source_graph: crate::profile_archive::ProfileSourceGraphSummary {
|
||||||
|
source_count: 0,
|
||||||
|
total_source_bytes: 0,
|
||||||
|
entrypoints: std::collections::BTreeMap::new(),
|
||||||
|
import_count: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config_bundle: Some(ConfigBundleRef {
|
||||||
id: bundle.metadata.id,
|
id: bundle.metadata.id,
|
||||||
digest: bundle.metadata.digest,
|
digest: bundle.metadata.digest,
|
||||||
},
|
}),
|
||||||
initial_input: None,
|
initial_input: None,
|
||||||
working_directory_request: None,
|
working_directory_request: None,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
|
|
@ -977,8 +1030,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let input_ack: RuntimeHttpWorkerInputResponse = read_json(response).await;
|
let _input_ack: RuntimeHttpWorkerInputResponse = read_json(response).await;
|
||||||
assert_eq!(input_ack.ack.transcript_sequence, 1);
|
|
||||||
|
|
||||||
let response = empty_request(
|
let response = empty_request(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
|
|
@ -987,21 +1039,15 @@ mod tests {
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let detail: RuntimeHttpWorkerResponse = read_json(response).await;
|
let _detail: RuntimeHttpWorkerResponse = read_json(response).await;
|
||||||
assert_eq!(detail.worker.transcript_len, 1);
|
|
||||||
|
|
||||||
let response = empty_request(
|
let response = empty_request(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&format!(
|
&format!("/v1/workers/{}/transcript", created.worker.worker_id),
|
||||||
"/v1/workers/{}/transcript?start=0&limit=1",
|
|
||||||
created.worker.worker_id
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
let transcript: RuntimeHttpTranscriptResponse = read_json(response).await;
|
|
||||||
assert_eq!(transcript.transcript.items[0].content, "hello from backend");
|
|
||||||
|
|
||||||
let response = empty_request(
|
let response = empty_request(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
|
|
@ -1027,7 +1073,6 @@ mod tests {
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let workers: RuntimeHttpWorkersResponse = read_json(response).await;
|
let workers: RuntimeHttpWorkersResponse = read_json(response).await;
|
||||||
assert_eq!(workers.workers.len(), 1);
|
assert_eq!(workers.workers.len(), 1);
|
||||||
assert_eq!(workers.workers[0].transcript_len, 1);
|
|
||||||
|
|
||||||
let response = empty_request(app, Method::GET, "/v1/runtime").await;
|
let response = empty_request(app, Method::GET, "/v1/runtime").await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
@ -1147,10 +1192,27 @@ mod ws_tests {
|
||||||
let bundle = ws_test_bundle(ProfileSelector::RuntimeDefault);
|
let bundle = ws_test_bundle(ProfileSelector::RuntimeDefault);
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
profile: ProfileSelector::RuntimeDefault,
|
profile: ProfileSelector::RuntimeDefault,
|
||||||
config_bundle: ConfigBundleRef {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
||||||
|
location: crate::catalog::ProfileSourceArchiveHttpRef {
|
||||||
|
url: "http://127.0.0.1/profile-source.tar".to_string(),
|
||||||
|
etag: None,
|
||||||
|
archive: crate::profile_archive::ProfileSourceArchiveRef {
|
||||||
|
id: "test-profile-source".to_string(),
|
||||||
|
digest: "test-digest".to_string(),
|
||||||
|
size_bytes: 0,
|
||||||
|
source_graph: crate::profile_archive::ProfileSourceGraphSummary {
|
||||||
|
source_count: 0,
|
||||||
|
total_source_bytes: 0,
|
||||||
|
entrypoints: std::collections::BTreeMap::new(),
|
||||||
|
import_count: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config_bundle: Some(ConfigBundleRef {
|
||||||
id: bundle.metadata.id,
|
id: bundle.metadata.id,
|
||||||
digest: bundle.metadata.digest,
|
digest: bundle.metadata.digest,
|
||||||
},
|
}),
|
||||||
initial_input: None,
|
initial_input: None,
|
||||||
working_directory_request: None,
|
working_directory_request: None,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@ pub enum WorkerInputKind {
|
||||||
System,
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Worker input request. v0 stores the input in an in-memory transcript and
|
/// Worker input request accepted by a Runtime Worker.
|
||||||
/// does not execute providers/tools.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerInput {
|
pub struct WorkerInput {
|
||||||
pub kind: WorkerInputKind,
|
pub kind: WorkerInputKind,
|
||||||
|
|
@ -34,11 +33,10 @@ impl WorkerInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acknowledgement returned after input is accepted into the in-memory Worker.
|
/// Acknowledgement returned after input is accepted into the Worker.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerInteractionAck {
|
pub struct WorkerInteractionAck {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
pub transcript_sequence: u64,
|
|
||||||
pub event_id: u64,
|
pub event_id: u64,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,16 @@ fn working_directory_runtime_root(config: &ProcessConfig) -> PathBuf {
|
||||||
_ => config
|
_ => config
|
||||||
.worker_runtime_base_dir
|
.worker_runtime_base_dir
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| env::temp_dir().join("yoi-worker-runtime")),
|
.unwrap_or_else(default_working_directory_runtime_root),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_working_directory_runtime_root() -> PathBuf {
|
||||||
|
manifest::paths::data_dir()
|
||||||
|
.map(|data_dir| data_dir.join("worker-runtime"))
|
||||||
|
.unwrap_or_else(|| env::temp_dir().join("yoi-worker-runtime"))
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||||
RuntimeOptions {
|
RuntimeOptions {
|
||||||
runtime_id: config.runtime_id.clone(),
|
runtime_id: config.runtime_id.clone(),
|
||||||
|
|
@ -232,10 +238,6 @@ where
|
||||||
}
|
}
|
||||||
config.http.local_token = Some(value);
|
config.http.local_token = Some(value);
|
||||||
}
|
}
|
||||||
"--max-transcript-projection-items" => {
|
|
||||||
config.http.limits.max_transcript_projection_items =
|
|
||||||
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
|
|
||||||
}
|
|
||||||
"--max-event-batch-items" => {
|
"--max-event-batch-items" => {
|
||||||
config.http.limits.max_event_batch_items =
|
config.http.limits.max_event_batch_items =
|
||||||
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
|
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
|
||||||
|
|
@ -430,7 +432,6 @@ Options:\n\
|
||||||
--fs-root <PATH> Runtime catalog filesystem store root\n\
|
--fs-root <PATH> Runtime catalog filesystem store root\n\
|
||||||
--local-token <TOKEN> Minimal local bearer token placeholder\n\
|
--local-token <TOKEN> Minimal local bearer token placeholder\n\
|
||||||
--local-token-env <ENV> Read local bearer token placeholder from env\n\
|
--local-token-env <ENV> Read local bearer token placeholder from env\n\
|
||||||
--max-transcript-projection-items <N> Override transcript projection limit\n\
|
|
||||||
--max-event-batch-items <N> Override event batch limit\n\
|
--max-event-batch-items <N> Override event batch limit\n\
|
||||||
-h, --help Show this help"
|
-h, --help Show this help"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,15 @@ pub enum RuntimeStatus {
|
||||||
Stopped,
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Guardrails for bounded observation/projection APIs.
|
/// Guardrails for bounded Runtime APIs.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeLimits {
|
pub struct RuntimeLimits {
|
||||||
pub max_transcript_projection_items: usize,
|
|
||||||
pub max_event_batch_items: usize,
|
pub max_event_batch_items: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RuntimeLimits {
|
impl Default for RuntimeLimits {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_transcript_projection_items: 256,
|
|
||||||
max_event_batch_items: 256,
|
max_event_batch_items: 256,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,6 @@
|
||||||
use crate::identity::{RuntimeId, WorkerRef};
|
use crate::identity::{RuntimeId, WorkerRef};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Transcript role used by bounded projection.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TranscriptRole {
|
|
||||||
User,
|
|
||||||
Assistant,
|
|
||||||
System,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One projected transcript item.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TranscriptEntry {
|
|
||||||
pub sequence: u64,
|
|
||||||
pub worker_ref: WorkerRef,
|
|
||||||
pub role: TranscriptRole,
|
|
||||||
pub content: String,
|
|
||||||
pub event_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bounded transcript query.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TranscriptQuery {
|
|
||||||
pub start: usize,
|
|
||||||
pub limit: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TranscriptQuery {
|
|
||||||
pub fn new(start: usize, limit: usize) -> Self {
|
|
||||||
Self { start, limit }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bounded transcript projection response.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TranscriptProjection {
|
|
||||||
pub worker_ref: WorkerRef,
|
|
||||||
pub start: usize,
|
|
||||||
pub limit: usize,
|
|
||||||
pub total_items: usize,
|
|
||||||
pub items: Vec<TranscriptEntry>,
|
|
||||||
pub next_start: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event cursor. `next_event_id` is the first event id that should be returned
|
/// Event cursor. `next_event_id` is the first event id that should be returned
|
||||||
/// by the next poll.
|
/// by the next poll.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use decodal::{Engine, LoadedSource, SourceLoader};
|
||||||
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::io::{Cursor, Read, Write};
|
use std::io::{Cursor, Read, Write};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use tar::{Archive, Builder, Header};
|
use tar::{Archive, Builder, Header};
|
||||||
|
|
@ -27,6 +27,10 @@ pub enum ProfileArchiveError {
|
||||||
MissingManifest,
|
MissingManifest,
|
||||||
#[error("profile source archive missing source {0}")]
|
#[error("profile source archive missing source {0}")]
|
||||||
MissingSource(String),
|
MissingSource(String),
|
||||||
|
#[error("profile source archive contains duplicate source {0}")]
|
||||||
|
DuplicateSource(String),
|
||||||
|
#[error("profile source archive contains source not declared in manifest {0}")]
|
||||||
|
UndeclaredSource(String),
|
||||||
#[error(
|
#[error(
|
||||||
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
|
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
|
||||||
)]
|
)]
|
||||||
|
|
@ -47,6 +51,14 @@ pub enum ProfileArchiveError {
|
||||||
MissingEntrypoint(String),
|
MissingEntrypoint(String),
|
||||||
#[error("profile source archive import is not declared in manifest import map: {0}")]
|
#[error("profile source archive import is not declared in manifest import map: {0}")]
|
||||||
ImportNotDeclared(String),
|
ImportNotDeclared(String),
|
||||||
|
#[error(
|
||||||
|
"profile source archive import map mismatch for {specifier}: expected {expected}, got {actual}"
|
||||||
|
)]
|
||||||
|
ImportMapMismatch {
|
||||||
|
specifier: String,
|
||||||
|
expected: String,
|
||||||
|
actual: String,
|
||||||
|
},
|
||||||
#[error("failed to read profile source archive: {0}")]
|
#[error("failed to read profile source archive: {0}")]
|
||||||
Io(String),
|
Io(String),
|
||||||
#[error("failed to parse profile source archive manifest: {0}")]
|
#[error("failed to parse profile source archive manifest: {0}")]
|
||||||
|
|
@ -94,7 +106,11 @@ pub struct ProfileSourceArchiveManifest {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ProfileSourceArchiveSource {
|
pub struct ProfileSourceArchiveSource {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_key: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
#[serde(default = "default_decodal_content_type")]
|
||||||
|
pub content_type: String,
|
||||||
pub digest: String,
|
pub digest: String,
|
||||||
pub size_bytes: u64,
|
pub size_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +144,9 @@ impl ProfileSourceArchive {
|
||||||
}
|
}
|
||||||
source_meta.push(ProfileSourceArchiveSource {
|
source_meta.push(ProfileSourceArchiveSource {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
source_key: path.clone(),
|
||||||
kind: "decodal".to_string(),
|
kind: "decodal".to_string(),
|
||||||
|
content_type: default_decodal_content_type(),
|
||||||
digest: sha256_hex(source.as_bytes()),
|
digest: sha256_hex(source.as_bytes()),
|
||||||
size_bytes: size,
|
size_bytes: size,
|
||||||
});
|
});
|
||||||
|
|
@ -238,7 +256,9 @@ impl VerifiedProfileSourceArchive {
|
||||||
} else {
|
} else {
|
||||||
let source = String::from_utf8(data)
|
let source = String::from_utf8(data)
|
||||||
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
||||||
entries.insert(path, source);
|
if entries.insert(path.clone(), source).is_some() {
|
||||||
|
return Err(ProfileArchiveError::DuplicateSource(path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if entries.len() > MAX_SOURCES {
|
if entries.len() > MAX_SOURCES {
|
||||||
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||||
|
|
@ -258,8 +278,25 @@ impl VerifiedProfileSourceArchive {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
|
let mut manifest_paths = BTreeSet::new();
|
||||||
for source in &manifest.sources {
|
for source in &manifest.sources {
|
||||||
validate_archive_path(&source.path)?;
|
validate_archive_path(&source.path)?;
|
||||||
|
if !source.source_key.is_empty() && source.source_key != source.path {
|
||||||
|
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||||
|
specifier: source.path.clone(),
|
||||||
|
expected: source.path.clone(),
|
||||||
|
actual: source.source_key.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if source.content_type != default_decodal_content_type() {
|
||||||
|
return Err(ProfileArchiveError::UnsupportedSource {
|
||||||
|
path: source.path.clone(),
|
||||||
|
kind: source.content_type.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !manifest_paths.insert(source.path.clone()) {
|
||||||
|
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
|
||||||
|
}
|
||||||
if source.kind != "decodal" {
|
if source.kind != "decodal" {
|
||||||
return Err(ProfileArchiveError::UnsupportedSource {
|
return Err(ProfileArchiveError::UnsupportedSource {
|
||||||
path: source.path.clone(),
|
path: source.path.clone(),
|
||||||
|
|
@ -285,6 +322,11 @@ impl VerifiedProfileSourceArchive {
|
||||||
}
|
}
|
||||||
total += size;
|
total += size;
|
||||||
}
|
}
|
||||||
|
for entry_path in entries.keys() {
|
||||||
|
if !manifest_paths.contains(entry_path) {
|
||||||
|
return Err(ProfileArchiveError::UndeclaredSource(entry_path.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
if total != reference.source_graph.total_source_bytes
|
if total != reference.source_graph.total_source_bytes
|
||||||
|| manifest.sources.len() != reference.source_graph.source_count
|
|| manifest.sources.len() != reference.source_graph.source_count
|
||||||
{
|
{
|
||||||
|
|
@ -298,10 +340,13 @@ impl VerifiedProfileSourceArchive {
|
||||||
.chain(manifest.imports.values())
|
.chain(manifest.imports.values())
|
||||||
{
|
{
|
||||||
validate_archive_path(path)?;
|
validate_archive_path(path)?;
|
||||||
if !entries.contains_key(path) {
|
if !manifest_paths.contains(path) || !entries.contains_key(path) {
|
||||||
return Err(ProfileArchiveError::MissingSource(path.clone()));
|
return Err(ProfileArchiveError::MissingSource(path.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (key, path) in &manifest.imports {
|
||||||
|
validate_archive_import_map_entry(key, path, &manifest_paths)?;
|
||||||
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
reference,
|
reference,
|
||||||
manifest,
|
manifest,
|
||||||
|
|
@ -313,6 +358,10 @@ impl VerifiedProfileSourceArchive {
|
||||||
&self.reference
|
&self.reference
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn manifest(&self) -> &ProfileSourceArchiveManifest {
|
||||||
|
&self.manifest
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_profile(
|
pub fn resolve_profile(
|
||||||
&self,
|
&self,
|
||||||
selector: &str,
|
selector: &str,
|
||||||
|
|
@ -378,22 +427,26 @@ impl<'a> ArchiveSourceLoader<'a> {
|
||||||
impl SourceLoader for ArchiveSourceLoader<'_> {
|
impl SourceLoader for ArchiveSourceLoader<'_> {
|
||||||
fn load(
|
fn load(
|
||||||
&mut self,
|
&mut self,
|
||||||
_current_key: Option<&str>,
|
current_key: Option<&str>,
|
||||||
specifier: &str,
|
specifier: &str,
|
||||||
) -> decodal::Result<LoadedSource> {
|
) -> decodal::Result<LoadedSource> {
|
||||||
let path = self
|
let path =
|
||||||
.archive
|
archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
|
||||||
.manifest
|
.map_err(import_diagnostic)?;
|
||||||
.imports
|
if let Some(current_key) = current_key {
|
||||||
.get(specifier)
|
match resolve_archive_import_path(current_key, specifier) {
|
||||||
.ok_or_else(|| {
|
Ok(expected) if expected == path => {}
|
||||||
decodal::Diagnostic::new(
|
Ok(expected) => {
|
||||||
decodal::DiagnosticKind::Import,
|
return Err(import_diagnostic(ProfileArchiveError::ImportMapMismatch {
|
||||||
decodal::Span::default(),
|
specifier: format!("{current_key}\0{specifier}"),
|
||||||
format!("archive import not declared: {specifier}"),
|
expected,
|
||||||
)
|
actual: path.clone(),
|
||||||
})?;
|
}));
|
||||||
let source = self.archive.sources.get(path).ok_or_else(|| {
|
}
|
||||||
|
Err(err) => return Err(import_diagnostic(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let source = self.archive.sources.get(&path).ok_or_else(|| {
|
||||||
decodal::Diagnostic::new(
|
decodal::Diagnostic::new(
|
||||||
decodal::DiagnosticKind::Import,
|
decodal::DiagnosticKind::Import,
|
||||||
decodal::Span::default(),
|
decodal::Span::default(),
|
||||||
|
|
@ -408,6 +461,123 @@ impl SourceLoader for ArchiveSourceLoader<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_decodal_content_type() -> String {
|
||||||
|
"text/x-decodal".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_diagnostic(err: ProfileArchiveError) -> decodal::Diagnostic {
|
||||||
|
decodal::Diagnostic::new(
|
||||||
|
decodal::DiagnosticKind::Import,
|
||||||
|
decodal::Span::default(),
|
||||||
|
err.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_import_map_lookup(
|
||||||
|
imports: &BTreeMap<String, String>,
|
||||||
|
current_key: Option<&str>,
|
||||||
|
specifier: &str,
|
||||||
|
) -> Result<String, ProfileArchiveError> {
|
||||||
|
if let Some(current_key) = current_key {
|
||||||
|
let scoped = format!("{current_key}\0{specifier}");
|
||||||
|
if let Some(path) = imports.get(&scoped) {
|
||||||
|
return Ok(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imports
|
||||||
|
.get(specifier)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ProfileArchiveError::ImportNotDeclared(specifier.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_archive_import_map_entry(
|
||||||
|
key: &str,
|
||||||
|
path: &str,
|
||||||
|
manifest_paths: &BTreeSet<String>,
|
||||||
|
) -> Result<(), ProfileArchiveError> {
|
||||||
|
if !manifest_paths.contains(path) {
|
||||||
|
return Err(ProfileArchiveError::MissingSource(path.to_string()));
|
||||||
|
}
|
||||||
|
if let Some((current_key, specifier)) = key.split_once('\0') {
|
||||||
|
validate_archive_path(current_key)?;
|
||||||
|
let expected = resolve_archive_import_path(current_key, specifier)?;
|
||||||
|
if expected != path {
|
||||||
|
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||||
|
specifier: key.to_string(),
|
||||||
|
expected,
|
||||||
|
actual: path.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
validate_archive_import_specifier(key)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_archive_import_path(
|
||||||
|
current_key: &str,
|
||||||
|
specifier: &str,
|
||||||
|
) -> Result<String, ProfileArchiveError> {
|
||||||
|
validate_archive_path(current_key)?;
|
||||||
|
validate_archive_import_specifier(specifier)?;
|
||||||
|
let raw = if let Some(rest) = specifier.strip_prefix("project:") {
|
||||||
|
rest
|
||||||
|
} else if let Some(rest) = specifier.strip_prefix("workspace:") {
|
||||||
|
rest
|
||||||
|
} else {
|
||||||
|
specifier
|
||||||
|
};
|
||||||
|
let base = if raw.starts_with("./") || raw.starts_with("../") {
|
||||||
|
Path::new(current_key)
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(raw)
|
||||||
|
} else {
|
||||||
|
PathBuf::from(raw)
|
||||||
|
};
|
||||||
|
normalize_archive_virtual_path(&base.to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_archive_import_specifier(specifier: &str) -> Result<(), ProfileArchiveError> {
|
||||||
|
if specifier.is_empty()
|
||||||
|
|| specifier.contains("://")
|
||||||
|
|| specifier.starts_with('/')
|
||||||
|
|| Path::new(specifier).is_absolute()
|
||||||
|
{
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||||
|
}
|
||||||
|
if let Some((namespace, rest)) = specifier.split_once(':') {
|
||||||
|
if namespace != "project" && namespace != "workspace" {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||||
|
}
|
||||||
|
if rest.is_empty() || rest.starts_with('/') || rest.contains("://") {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_archive_virtual_path(path: &str) -> Result<String, ProfileArchiveError> {
|
||||||
|
let path_ref = Path::new(path);
|
||||||
|
if path_ref.is_absolute() {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||||
|
}
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for component in path_ref.components() {
|
||||||
|
match component {
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::Normal(value) => out.push(value),
|
||||||
|
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let normalized = out.to_string_lossy().replace('\\', "/");
|
||||||
|
validate_archive_path(&normalized)?;
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
|
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
|
||||||
match data {
|
match data {
|
||||||
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
|
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
|
||||||
|
|
@ -551,4 +721,56 @@ mod tests {
|
||||||
let mut loader = ArchiveSourceLoader::new(&verified);
|
let mut loader = ArchiveSourceLoader::new(&verified);
|
||||||
assert!(loader.load(None, "missing").is_err());
|
assert!(loader.load(None, "missing").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_loader_resolves_scoped_virtual_imports() {
|
||||||
|
let mut entrypoints = BTreeMap::new();
|
||||||
|
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
|
||||||
|
let mut imports = BTreeMap::new();
|
||||||
|
imports.insert(
|
||||||
|
"profiles/main.dcdl\0./shared.dcdl".to_string(),
|
||||||
|
"profiles/shared.dcdl".to_string(),
|
||||||
|
);
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
|
||||||
|
sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string());
|
||||||
|
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||||
|
id: "project".to_string(),
|
||||||
|
entrypoints,
|
||||||
|
imports,
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let verified = archive.verify().unwrap();
|
||||||
|
let mut loader = ArchiveSourceLoader::new(&verified);
|
||||||
|
let loaded = loader
|
||||||
|
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loaded.key, "profiles/shared.dcdl");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_verify_rejects_import_map_that_bypasses_virtual_resolver() {
|
||||||
|
let mut entrypoints = BTreeMap::new();
|
||||||
|
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
|
||||||
|
let mut imports = BTreeMap::new();
|
||||||
|
imports.insert(
|
||||||
|
"profiles/main.dcdl\0./shared.dcdl".to_string(),
|
||||||
|
"profiles/other.dcdl".to_string(),
|
||||||
|
);
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
|
||||||
|
sources.insert("profiles/other.dcdl".to_string(), "{}".to_string());
|
||||||
|
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||||
|
id: "project".to_string(),
|
||||||
|
entrypoints,
|
||||||
|
imports,
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
archive.verify(),
|
||||||
|
Err(ProfileArchiveError::ImportMapMismatch { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use crate::catalog::{
|
use crate::catalog::{
|
||||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
|
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerStatus,
|
||||||
WorkerStatus, WorkerSummary, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus,
|
WorkerSummary, WorkingDirectoryRequest,
|
||||||
|
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus,
|
||||||
};
|
};
|
||||||
use crate::config_bundle::{
|
use crate::config_bundle::{
|
||||||
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
|
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
|
||||||
|
|
@ -27,7 +28,7 @@ use crate::management::{
|
||||||
};
|
};
|
||||||
use crate::observation::{
|
use crate::observation::{
|
||||||
EventCursor, EventSubscription, EventSubscriptionMode, RuntimeEvent, RuntimeEventBatch,
|
EventCursor, EventSubscription, EventSubscriptionMode, RuntimeEvent, RuntimeEventBatch,
|
||||||
RuntimeEventKind, TranscriptEntry, TranscriptProjection, TranscriptQuery, TranscriptRole,
|
RuntimeEventKind,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
||||||
|
|
@ -234,7 +235,80 @@ impl Runtime {
|
||||||
Ok(event_id)
|
Ok(event_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a Worker through the canonical ConfigBundle + execution backend path.
|
/// Create a Runtime-owned working directory through the attached execution backend.
|
||||||
|
pub fn create_working_directory(
|
||||||
|
&self,
|
||||||
|
request: WorkingDirectoryRequest,
|
||||||
|
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.ensure_running()?;
|
||||||
|
state.execution_backend.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "working directory creation requires an execution backend".to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.create_working_directory(&request)
|
||||||
|
.map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List Runtime-owned working directories through the attached execution backend.
|
||||||
|
pub fn list_working_directories(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<CatalogWorkingDirectoryStatus>, RuntimeError> {
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.ensure_running()?;
|
||||||
|
state.execution_backend.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "working directory listing requires an execution backend".to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
Ok(backend.list_working_directories())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a Runtime-owned working directory status.
|
||||||
|
pub fn working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.ensure_running()?;
|
||||||
|
state.execution_backend.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "working directory lookup requires an execution backend".to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.working_directory(working_directory_id)
|
||||||
|
.map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleanup a Runtime-owned working directory.
|
||||||
|
pub fn cleanup_working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||||
|
let backend = {
|
||||||
|
let state = self.lock()?;
|
||||||
|
state.ensure_running()?;
|
||||||
|
state.execution_backend.clone().ok_or_else(|| {
|
||||||
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
|
message: "working directory cleanup requires an execution backend".to_string(),
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.cleanup_working_directory(working_directory_id)
|
||||||
|
.map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Worker through the canonical profile-source + execution backend path.
|
||||||
pub fn create_worker(
|
pub fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
|
|
@ -244,13 +318,6 @@ impl Runtime {
|
||||||
state.ensure_running()?;
|
state.ensure_running()?;
|
||||||
validate_create_worker_request(&request)?;
|
validate_create_worker_request(&request)?;
|
||||||
state.validate_worker_config_boundary(&request)?;
|
state.validate_worker_config_boundary(&request)?;
|
||||||
let config_bundle = state
|
|
||||||
.config_bundles
|
|
||||||
.get(&request.config_bundle.id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| RuntimeError::ConfigBundleMissing {
|
|
||||||
bundle_id: request.config_bundle.id.clone(),
|
|
||||||
})?;
|
|
||||||
let backend = state.execution_backend.clone().ok_or_else(|| {
|
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||||
RuntimeError::ExecutionBackendUnavailable {
|
RuntimeError::ExecutionBackendUnavailable {
|
||||||
message: "worker creation requires an execution backend".to_string(),
|
message: "worker creation requires an execution backend".to_string(),
|
||||||
|
|
@ -266,19 +333,6 @@ impl Runtime {
|
||||||
format!("worker {worker_id} created"),
|
format!("worker {worker_id} created"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut transcript = Vec::new();
|
|
||||||
let mut next_transcript_sequence = 1;
|
|
||||||
if let Some(input) = request.initial_input.clone() {
|
|
||||||
transcript.push(TranscriptEntry {
|
|
||||||
sequence: next_transcript_sequence,
|
|
||||||
worker_ref: worker_ref.clone(),
|
|
||||||
role: TranscriptRole::User,
|
|
||||||
content: input.content,
|
|
||||||
event_id,
|
|
||||||
});
|
|
||||||
next_transcript_sequence += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = WorkerRecord {
|
let record = WorkerRecord {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
worker_id: worker_id.clone(),
|
worker_id: worker_id.clone(),
|
||||||
|
|
@ -286,8 +340,6 @@ impl Runtime {
|
||||||
request: request.clone(),
|
request: request.clone(),
|
||||||
execution: WorkerExecutionStatus::unconnected(),
|
execution: WorkerExecutionStatus::unconnected(),
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
transcript,
|
|
||||||
next_transcript_sequence,
|
|
||||||
last_event_id: event_id,
|
last_event_id: event_id,
|
||||||
};
|
};
|
||||||
state.workers.insert(worker_id, record);
|
state.workers.insert(worker_id, record);
|
||||||
|
|
@ -296,7 +348,7 @@ impl Runtime {
|
||||||
request,
|
request,
|
||||||
context: self.execution_context(worker_ref.clone()),
|
context: self.execution_context(worker_ref.clone()),
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
config_bundle: Some(config_bundle),
|
config_bundle: None,
|
||||||
};
|
};
|
||||||
(backend, worker_ref, spawn_request)
|
(backend, worker_ref, spawn_request)
|
||||||
};
|
};
|
||||||
|
|
@ -325,7 +377,7 @@ impl Runtime {
|
||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
state.worker(&worker_ref)?.request.initial_input.clone()
|
state.worker(&worker_ref)?.request.initial_input.clone()
|
||||||
} {
|
} {
|
||||||
let dispatch_result = backend.dispatch_input(&handle, initial_input);
|
let dispatch_result = backend.dispatch_input(&handle, initial_input.clone());
|
||||||
if !dispatch_result.is_accepted() {
|
if !dispatch_result.is_accepted() {
|
||||||
let _ = backend.stop_worker(&handle);
|
let _ = backend.stop_worker(&handle);
|
||||||
self.rollback_failed_create(&worker_ref)?;
|
self.rollback_failed_create(&worker_ref)?;
|
||||||
|
|
@ -337,7 +389,7 @@ impl Runtime {
|
||||||
result: dispatch_result,
|
result: dispatch_result,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.commit_created_worker(
|
let detail = self.commit_created_worker(
|
||||||
&worker_ref,
|
&worker_ref,
|
||||||
handle,
|
handle,
|
||||||
WorkerExecutionRunState::Busy,
|
WorkerExecutionRunState::Busy,
|
||||||
|
|
@ -346,7 +398,9 @@ impl Runtime {
|
||||||
WorkerExecutionOperation::Input,
|
WorkerExecutionOperation::Input,
|
||||||
WorkerExecutionRunState::Busy,
|
WorkerExecutionRunState::Busy,
|
||||||
),
|
),
|
||||||
)
|
)?;
|
||||||
|
self.record_input_observation(&worker_ref, initial_input)?;
|
||||||
|
Ok(detail)
|
||||||
} else {
|
} else {
|
||||||
self.commit_created_worker(
|
self.commit_created_worker(
|
||||||
&worker_ref,
|
&worker_ref,
|
||||||
|
|
@ -375,7 +429,7 @@ impl Runtime {
|
||||||
Ok(worker.detail(&state.runtime_id))
|
Ok(worker.detail(&state.runtime_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept input into a Worker transcript.
|
/// Accept input into a Worker.
|
||||||
pub fn send_input(
|
pub fn send_input(
|
||||||
&self,
|
&self,
|
||||||
worker_ref: &WorkerRef,
|
worker_ref: &WorkerRef,
|
||||||
|
|
@ -435,14 +489,6 @@ impl Runtime {
|
||||||
"worker input accepted",
|
"worker input accepted",
|
||||||
);
|
);
|
||||||
let worker = state.worker_mut(worker_ref)?;
|
let worker = state.worker_mut(worker_ref)?;
|
||||||
|
|
||||||
let input_content = input.content;
|
|
||||||
let role = match input.kind {
|
|
||||||
WorkerInputKind::User => TranscriptRole::User,
|
|
||||||
WorkerInputKind::System => TranscriptRole::System,
|
|
||||||
};
|
|
||||||
let transcript_sequence = worker.next_transcript_sequence;
|
|
||||||
worker.next_transcript_sequence += 1;
|
|
||||||
worker.last_event_id = event_id;
|
worker.last_event_id = event_id;
|
||||||
worker.execution = WorkerExecutionStatus {
|
worker.execution = WorkerExecutionStatus {
|
||||||
backend: WorkerExecutionBackendKind::Connected,
|
backend: WorkerExecutionBackendKind::Connected,
|
||||||
|
|
@ -451,44 +497,24 @@ impl Runtime {
|
||||||
working_directory: worker.execution.working_directory.clone(),
|
working_directory: worker.execution.working_directory.clone(),
|
||||||
last_result: Some(dispatch_result),
|
last_result: Some(dispatch_result),
|
||||||
};
|
};
|
||||||
worker.transcript.push(TranscriptEntry {
|
|
||||||
sequence: transcript_sequence,
|
|
||||||
worker_ref: worker_ref.clone(),
|
|
||||||
role,
|
|
||||||
content: input_content.clone(),
|
|
||||||
event_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
let status = worker.status;
|
let status = worker.status;
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
{
|
let observation = {
|
||||||
let payload = match role {
|
let payload = input_protocol_event(&input);
|
||||||
TranscriptRole::User => protocol::Event::UserMessage {
|
Some(state.push_worker_observation_event(worker_ref.clone(), payload))
|
||||||
segments: vec![protocol::Segment::Text {
|
|
||||||
content: input_content.clone(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
TranscriptRole::Assistant => protocol::Event::TextDone {
|
|
||||||
text: input_content.clone(),
|
|
||||||
},
|
|
||||||
TranscriptRole::System => protocol::Event::SystemItem {
|
|
||||||
item: serde_json::json!({
|
|
||||||
"kind": "embedded_worker_system_input",
|
|
||||||
"content": input_content.clone(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
state.push_worker_observation_event(worker_ref.clone(), payload);
|
|
||||||
}
|
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
state.persist_event_by_id(event_id)?;
|
state.persist_event_by_id(event_id)?;
|
||||||
state.persist_transcript_entry(&worker_ref.worker_id, transcript_sequence)?;
|
#[cfg(feature = "ws-server")]
|
||||||
|
if let Some(observation) = observation.as_ref() {
|
||||||
|
state.persist_worker_observation_event(observation)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(WorkerInteractionAck {
|
Ok(WorkerInteractionAck {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
status,
|
status,
|
||||||
transcript_sequence,
|
|
||||||
event_id,
|
event_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -516,10 +542,6 @@ impl Runtime {
|
||||||
};
|
};
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
let worker = state.worker(worker_ref)?;
|
|
||||||
for entry in &worker.transcript {
|
|
||||||
state.persist_transcript_entry(&worker_ref.worker_id, entry.sequence)?;
|
|
||||||
}
|
|
||||||
state.persist_event_by_id(detail.last_event_id)?;
|
state.persist_event_by_id(detail.last_event_id)?;
|
||||||
Ok(detail)
|
Ok(detail)
|
||||||
}
|
}
|
||||||
|
|
@ -624,38 +646,6 @@ impl Runtime {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded transcript projection for a Worker.
|
|
||||||
pub fn transcript_projection(
|
|
||||||
&self,
|
|
||||||
worker_ref: &WorkerRef,
|
|
||||||
query: TranscriptQuery,
|
|
||||||
) -> Result<TranscriptProjection, RuntimeError> {
|
|
||||||
let state = self.lock()?;
|
|
||||||
if query.limit > state.limits.max_transcript_projection_items {
|
|
||||||
return Err(RuntimeError::LimitTooLarge {
|
|
||||||
requested: query.limit,
|
|
||||||
max: state.limits.max_transcript_projection_items,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let worker = state.worker(worker_ref)?;
|
|
||||||
let total_items = worker.transcript.len();
|
|
||||||
let end = query.start.saturating_add(query.limit).min(total_items);
|
|
||||||
let items = if query.start >= total_items {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
worker.transcript[query.start..end].to_vec()
|
|
||||||
};
|
|
||||||
let next_start = (end < total_items).then_some(end);
|
|
||||||
Ok(TranscriptProjection {
|
|
||||||
worker_ref: worker_ref.clone(),
|
|
||||||
start: query.start,
|
|
||||||
limit: query.limit,
|
|
||||||
total_items,
|
|
||||||
items,
|
|
||||||
next_start,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cursor pointing to the beginning of Runtime events.
|
/// Cursor pointing to the beginning of Runtime events.
|
||||||
pub fn event_cursor_from_start(&self) -> Result<EventCursor, RuntimeError> {
|
pub fn event_cursor_from_start(&self) -> Result<EventCursor, RuntimeError> {
|
||||||
let state = self.lock()?;
|
let state = self.lock()?;
|
||||||
|
|
@ -813,16 +803,13 @@ impl Runtime {
|
||||||
) -> Result<WorkerObservationEvent, RuntimeError> {
|
) -> Result<WorkerObservationEvent, RuntimeError> {
|
||||||
let mut state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
state.ensure_worker_ref(worker_ref)?;
|
state.ensure_worker_ref(worker_ref)?;
|
||||||
let transcript_sequence = state.project_protocol_event_to_transcript(worker_ref, &payload);
|
|
||||||
let execution_state_changed =
|
let execution_state_changed =
|
||||||
state.project_protocol_event_to_execution(worker_ref, &payload);
|
state.project_protocol_event_to_execution(worker_ref, &payload);
|
||||||
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||||
if transcript_sequence.is_some() || execution_state_changed {
|
if execution_state_changed {
|
||||||
state.persist_worker(&worker_ref.worker_id)?;
|
state.persist_worker(&worker_ref.worker_id)?;
|
||||||
}
|
}
|
||||||
if let Some(sequence) = transcript_sequence {
|
state.persist_worker_observation_event(&event)?;
|
||||||
state.persist_transcript_entry(&worker_ref.worker_id, sequence)?;
|
|
||||||
}
|
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -831,6 +818,29 @@ impl Runtime {
|
||||||
Ok(self.lock()?.diagnostics.clone())
|
Ok(self.lock()?.diagnostics.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
fn record_input_observation(
|
||||||
|
&self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
input: WorkerInput,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let mut state = self.lock()?;
|
||||||
|
state.ensure_worker_ref(worker_ref)?;
|
||||||
|
let event =
|
||||||
|
state.push_worker_observation_event(worker_ref.clone(), input_protocol_event(&input));
|
||||||
|
state.persist_worker_observation_event(&event)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ws-server"))]
|
||||||
|
fn record_input_observation(
|
||||||
|
&self,
|
||||||
|
_worker_ref: &WorkerRef,
|
||||||
|
_input: WorkerInput,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn transition_worker(
|
fn transition_worker(
|
||||||
&self,
|
&self,
|
||||||
worker_ref: &WorkerRef,
|
worker_ref: &WorkerRef,
|
||||||
|
|
@ -1039,13 +1049,22 @@ impl RuntimeState {
|
||||||
request: worker.request,
|
request: worker.request,
|
||||||
execution,
|
execution,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
transcript: worker.transcript,
|
|
||||||
next_transcript_sequence: worker.next_transcript_sequence,
|
|
||||||
last_event_id: worker.last_event_id,
|
last_event_id: worker.last_event_id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
let next_observation_sequence = persisted
|
||||||
|
.observation_events
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.sequence)
|
||||||
|
.max()
|
||||||
|
.map(|sequence| sequence.saturating_add(1))
|
||||||
|
.unwrap_or(1);
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
let observation_events = persisted.observation_events.into_iter().collect();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
runtime_id: persisted.runtime_id,
|
runtime_id: persisted.runtime_id,
|
||||||
display_name: persisted.display_name,
|
display_name: persisted.display_name,
|
||||||
|
|
@ -1062,9 +1081,9 @@ impl RuntimeState {
|
||||||
events: persisted.events,
|
events: persisted.events,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
next_observation_sequence: 1,
|
next_observation_sequence,
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
observation_events: VecDeque::new(),
|
observation_events,
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
observation_tx: broadcast::channel(256).0,
|
observation_tx: broadcast::channel(256).0,
|
||||||
})
|
})
|
||||||
|
|
@ -1087,6 +1106,8 @@ impl RuntimeState {
|
||||||
.collect(),
|
.collect(),
|
||||||
config_bundles: self.config_bundles.clone(),
|
config_bundles: self.config_bundles.clone(),
|
||||||
events: self.events.clone(),
|
events: self.events.clone(),
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
observation_events: Vec::new(),
|
||||||
diagnostics: self.diagnostics.clone(),
|
diagnostics: self.diagnostics.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1139,36 +1160,25 @@ impl RuntimeState {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(all(feature = "fs-store", feature = "ws-server"))]
|
||||||
fn persist_transcript_entry(
|
fn persist_worker_observation_event(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &WorkerId,
|
event: &WorkerObservationEvent,
|
||||||
sequence: u64,
|
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
if let Some(store) = self.fs_store() {
|
if let Some(store) = self.fs_store() {
|
||||||
let worker =
|
store.append_worker_observation_event(event)?;
|
||||||
self.workers
|
|
||||||
.get(worker_id)
|
|
||||||
.ok_or_else(|| RuntimeError::WorkerNotFound {
|
|
||||||
runtime_id: self.runtime_id.clone(),
|
|
||||||
worker_id: worker_id.clone(),
|
|
||||||
})?;
|
|
||||||
let entry = worker
|
|
||||||
.transcript
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.sequence == sequence)
|
|
||||||
.ok_or_else(|| RuntimeError::StoreCorrupt {
|
|
||||||
operation: "persist transcript",
|
|
||||||
path: store.runtime_dir().to_path_buf(),
|
|
||||||
message: format!(
|
|
||||||
"transcript sequence {sequence} is missing from worker {worker_id}"
|
|
||||||
),
|
|
||||||
})?;
|
|
||||||
store.append_transcript_entry(entry)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(feature = "fs-store"), feature = "ws-server"))]
|
||||||
|
fn persist_worker_observation_event(
|
||||||
|
&self,
|
||||||
|
_event: &WorkerObservationEvent,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
fn persist_workers(&self) -> Result<(), RuntimeError> {
|
fn persist_workers(&self) -> Result<(), RuntimeError> {
|
||||||
if self.fs_store().is_some() {
|
if self.fs_store().is_some() {
|
||||||
|
|
@ -1194,15 +1204,6 @@ impl RuntimeState {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "fs-store"))]
|
|
||||||
fn persist_transcript_entry(
|
|
||||||
&self,
|
|
||||||
_worker_id: &WorkerId,
|
|
||||||
_sequence: u64,
|
|
||||||
) -> Result<(), RuntimeError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "fs-store"))]
|
#[cfg(not(feature = "fs-store"))]
|
||||||
fn persist_workers(&self) -> Result<(), RuntimeError> {
|
fn persist_workers(&self) -> Result<(), RuntimeError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -1243,23 +1244,8 @@ impl RuntimeState {
|
||||||
|
|
||||||
fn validate_worker_config_boundary(
|
fn validate_worker_config_boundary(
|
||||||
&self,
|
&self,
|
||||||
request: &CreateWorkerRequest,
|
_request: &CreateWorkerRequest,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
let reference = &request.config_bundle;
|
|
||||||
let availability = self.check_config_bundle_ref(reference)?;
|
|
||||||
let bundle = self
|
|
||||||
.config_bundles
|
|
||||||
.get(&availability.reference.id)
|
|
||||||
.ok_or_else(|| RuntimeError::ConfigBundleMissing {
|
|
||||||
bundle_id: availability.reference.id.clone(),
|
|
||||||
})?;
|
|
||||||
if !bundle.contains_profile(&request.profile) {
|
|
||||||
return Err(RuntimeError::InvalidProfileSelector {
|
|
||||||
profile: profile_label(&request.profile),
|
|
||||||
bundle_id: Some(reference.id.clone()),
|
|
||||||
message: "profile selector is not declared by synced config bundle".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1369,66 +1355,6 @@ impl RuntimeState {
|
||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
|
||||||
fn append_worker_transcript_entry(
|
|
||||||
&mut self,
|
|
||||||
worker_ref: &WorkerRef,
|
|
||||||
role: TranscriptRole,
|
|
||||||
content: impl Into<String>,
|
|
||||||
) -> Option<u64> {
|
|
||||||
let content = content.into();
|
|
||||||
if content.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let event_id = self.last_event_id();
|
|
||||||
let worker = self.workers.get_mut(&worker_ref.worker_id)?;
|
|
||||||
let sequence = worker.next_transcript_sequence;
|
|
||||||
worker.next_transcript_sequence += 1;
|
|
||||||
worker.transcript.push(TranscriptEntry {
|
|
||||||
sequence,
|
|
||||||
worker_ref: worker_ref.clone(),
|
|
||||||
role,
|
|
||||||
content,
|
|
||||||
event_id,
|
|
||||||
});
|
|
||||||
Some(sequence)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
|
||||||
fn project_protocol_event_to_transcript(
|
|
||||||
&mut self,
|
|
||||||
worker_ref: &WorkerRef,
|
|
||||||
event: &protocol::Event,
|
|
||||||
) -> Option<u64> {
|
|
||||||
match event {
|
|
||||||
protocol::Event::TextDone { text, .. } => self.append_worker_transcript_entry(
|
|
||||||
worker_ref,
|
|
||||||
TranscriptRole::Assistant,
|
|
||||||
text.clone(),
|
|
||||||
),
|
|
||||||
protocol::Event::Error { message, .. } => self.append_worker_transcript_entry(
|
|
||||||
worker_ref,
|
|
||||||
TranscriptRole::System,
|
|
||||||
format!("error: {message}"),
|
|
||||||
),
|
|
||||||
protocol::Event::ToolResult {
|
|
||||||
id,
|
|
||||||
summary,
|
|
||||||
is_error,
|
|
||||||
..
|
|
||||||
} => self.append_worker_transcript_entry(
|
|
||||||
worker_ref,
|
|
||||||
TranscriptRole::System,
|
|
||||||
format!(
|
|
||||||
"tool result {id}: {}{}",
|
|
||||||
if *is_error { "error: " } else { "" },
|
|
||||||
summary
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
fn project_protocol_event_to_execution(
|
fn project_protocol_event_to_execution(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -1483,8 +1409,6 @@ struct WorkerRecord {
|
||||||
request: CreateWorkerRequest,
|
request: CreateWorkerRequest,
|
||||||
execution: WorkerExecutionStatus,
|
execution: WorkerExecutionStatus,
|
||||||
execution_handle: Option<WorkerExecutionHandle>,
|
execution_handle: Option<WorkerExecutionHandle>,
|
||||||
transcript: Vec<TranscriptEntry>,
|
|
||||||
next_transcript_sequence: u64,
|
|
||||||
last_event_id: u64,
|
last_event_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1497,8 +1421,8 @@ impl WorkerRecord {
|
||||||
status: self.status,
|
status: self.status,
|
||||||
execution: self.execution.clone(),
|
execution: self.execution.clone(),
|
||||||
profile: self.request.profile.clone(),
|
profile: self.request.profile.clone(),
|
||||||
|
profile_source: self.request.profile_source.reference(),
|
||||||
config_bundle: self.request.config_bundle.clone(),
|
config_bundle: self.request.config_bundle.clone(),
|
||||||
transcript_len: self.transcript.len(),
|
|
||||||
last_event_id: self.last_event_id,
|
last_event_id: self.last_event_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1511,8 +1435,8 @@ impl WorkerRecord {
|
||||||
status: self.status,
|
status: self.status,
|
||||||
execution: self.execution.clone(),
|
execution: self.execution.clone(),
|
||||||
profile: self.request.profile.clone(),
|
profile: self.request.profile.clone(),
|
||||||
|
profile_source: self.request.profile_source.reference(),
|
||||||
config_bundle: self.request.config_bundle.clone(),
|
config_bundle: self.request.config_bundle.clone(),
|
||||||
transcript_len: self.transcript.len(),
|
|
||||||
last_event_id: self.last_event_id,
|
last_event_id: self.last_event_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1525,32 +1449,31 @@ impl WorkerRecord {
|
||||||
status: self.status,
|
status: self.status,
|
||||||
request: self.request.clone(),
|
request: self.request.clone(),
|
||||||
execution: self.execution.clone(),
|
execution: self.execution.clone(),
|
||||||
transcript: self.transcript.clone(),
|
|
||||||
next_transcript_sequence: self.next_transcript_sequence,
|
|
||||||
last_event_id: self.last_event_id,
|
last_event_id: self.last_event_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_label(selector: &ProfileSelector) -> String {
|
|
||||||
match selector {
|
|
||||||
ProfileSelector::RuntimeDefault => "runtime_default".to_string(),
|
|
||||||
ProfileSelector::Builtin(value) => value.clone(),
|
|
||||||
ProfileSelector::Named(value) => value.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
|
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
|
||||||
if request.config_bundle.id.trim().is_empty() {
|
match &request.profile_source {
|
||||||
|
crate::catalog::ProfileSourceArchiveSource::Embedded { archive } => {
|
||||||
|
archive.verify().map_err(|err| {
|
||||||
|
RuntimeError::InvalidRequest(format!("profile_source archive is invalid: {err}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
crate::catalog::ProfileSourceArchiveSource::Http { location } => {
|
||||||
|
if location.url.trim().is_empty() {
|
||||||
return Err(RuntimeError::InvalidRequest(
|
return Err(RuntimeError::InvalidRequest(
|
||||||
"config_bundle.id must not be empty".to_string(),
|
"profile_source.location.url must not be empty".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if request.config_bundle.digest.trim().is_empty() {
|
if location.archive.digest.trim().is_empty() {
|
||||||
return Err(RuntimeError::InvalidRequest(
|
return Err(RuntimeError::InvalidRequest(
|
||||||
"config_bundle.digest must not be empty".to_string(),
|
"profile_source.location.archive.digest must not be empty".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(input) = &request.initial_input {
|
if let Some(input) = &request.initial_input {
|
||||||
if input.kind != WorkerInputKind::User {
|
if input.kind != WorkerInputKind::User {
|
||||||
return Err(RuntimeError::InvalidInitialInputKind {
|
return Err(RuntimeError::InvalidInitialInputKind {
|
||||||
|
|
@ -1575,6 +1498,23 @@ fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
|
fn input_protocol_event(input: &WorkerInput) -> protocol::Event {
|
||||||
|
match input.kind {
|
||||||
|
WorkerInputKind::User => protocol::Event::UserMessage {
|
||||||
|
segments: vec![protocol::Segment::Text {
|
||||||
|
content: input.content.clone(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
WorkerInputKind::System => protocol::Event::SystemItem {
|
||||||
|
item: serde_json::json!({
|
||||||
|
"kind": "embedded_worker_system_input",
|
||||||
|
"content": input.content.clone(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -1595,10 +1535,27 @@ mod tests {
|
||||||
let bundle = test_bundle_for_profile(profile.clone());
|
let bundle = test_bundle_for_profile(profile.clone());
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
profile,
|
profile,
|
||||||
config_bundle: ConfigBundleRef {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
|
||||||
|
location: crate::catalog::ProfileSourceArchiveHttpRef {
|
||||||
|
url: "http://127.0.0.1/profile-source.tar".to_string(),
|
||||||
|
etag: None,
|
||||||
|
archive: crate::profile_archive::ProfileSourceArchiveRef {
|
||||||
|
id: "test-profile-source".to_string(),
|
||||||
|
digest: "test-digest".to_string(),
|
||||||
|
size_bytes: 0,
|
||||||
|
source_graph: crate::profile_archive::ProfileSourceGraphSummary {
|
||||||
|
source_count: 0,
|
||||||
|
total_source_bytes: 0,
|
||||||
|
entrypoints: BTreeMap::new(),
|
||||||
|
import_count: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config_bundle: Some(ConfigBundleRef {
|
||||||
id: bundle.metadata.id,
|
id: bundle.metadata.id,
|
||||||
digest: bundle.metadata.digest,
|
digest: bundle.metadata.digest,
|
||||||
},
|
}),
|
||||||
initial_input: None,
|
initial_input: None,
|
||||||
working_directory_request: None,
|
working_directory_request: None,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
|
|
@ -1732,10 +1689,10 @@ mod tests {
|
||||||
|
|
||||||
fn bundled_task_request(objective: &str, bundle: &ConfigBundle) -> CreateWorkerRequest {
|
fn bundled_task_request(objective: &str, bundle: &ConfigBundle) -> CreateWorkerRequest {
|
||||||
let mut request = task_request(objective);
|
let mut request = task_request(objective);
|
||||||
request.config_bundle = ConfigBundleRef {
|
request.config_bundle = Some(ConfigBundleRef {
|
||||||
id: bundle.metadata.id.clone(),
|
id: bundle.metadata.id.clone(),
|
||||||
digest: bundle.metadata.digest.clone(),
|
digest: bundle.metadata.digest.clone(),
|
||||||
};
|
});
|
||||||
request
|
request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1746,7 +1703,7 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(detail.worker_ref.runtime_id, runtime.runtime_id().unwrap());
|
assert_eq!(detail.worker_ref.runtime_id, runtime.runtime_id().unwrap());
|
||||||
assert_eq!(detail.status, WorkerStatus::Running);
|
assert_eq!(detail.status, WorkerStatus::Running);
|
||||||
assert_eq!(detail.config_bundle.id, "bundle-1");
|
assert_eq!(detail.config_bundle.as_ref().unwrap().id, "bundle-1");
|
||||||
|
|
||||||
let list = runtime.list_workers().unwrap();
|
let list = runtime.list_workers().unwrap();
|
||||||
assert_eq!(list.len(), 1);
|
assert_eq!(list.len(), 1);
|
||||||
|
|
@ -1778,7 +1735,7 @@ mod tests {
|
||||||
let detail = runtime
|
let detail = runtime
|
||||||
.create_worker(bundled_task_request("synced", &bundle))
|
.create_worker(bundled_task_request("synced", &bundle))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(detail.config_bundle, availability.reference);
|
assert_eq!(detail.config_bundle, Some(availability.reference));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1786,11 +1743,6 @@ mod tests {
|
||||||
let runtime = Runtime::new_memory();
|
let runtime = Runtime::new_memory();
|
||||||
let bundle = test_bundle();
|
let bundle = test_bundle();
|
||||||
|
|
||||||
let missing = runtime
|
|
||||||
.create_worker(bundled_task_request("missing", &bundle))
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(missing, RuntimeError::ConfigBundleMissing { .. }));
|
|
||||||
|
|
||||||
runtime.store_config_bundle(bundle.clone()).unwrap();
|
runtime.store_config_bundle(bundle.clone()).unwrap();
|
||||||
let mismatch = runtime
|
let mismatch = runtime
|
||||||
.check_config_bundle(&ConfigBundleRef {
|
.check_config_bundle(&ConfigBundleRef {
|
||||||
|
|
@ -1803,14 +1755,6 @@ mod tests {
|
||||||
RuntimeError::ConfigBundleDigestMismatch { .. }
|
RuntimeError::ConfigBundleDigestMismatch { .. }
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut bad_profile = bundled_task_request("bad profile", &bundle);
|
|
||||||
bad_profile.profile = ProfileSelector::Builtin("builtin:reviewer".to_string());
|
|
||||||
let invalid_profile = runtime.create_worker(bad_profile).unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
invalid_profile,
|
|
||||||
RuntimeError::InvalidProfileSelector { .. }
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut unsupported = test_bundle();
|
let mut unsupported = test_bundle();
|
||||||
unsupported.declarations.push(ConfigDeclaration {
|
unsupported.declarations.push(ConfigDeclaration {
|
||||||
kind: ConfigDeclarationKind::Unsupported,
|
kind: ConfigDeclarationKind::Unsupported,
|
||||||
|
|
@ -1873,12 +1817,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_worker_missing_config_bundle_is_rejected_before_backend() {
|
fn create_worker_without_execution_backend_is_rejected_before_persisting_worker() {
|
||||||
let runtime = Runtime::new_memory();
|
let runtime = Runtime::new_memory();
|
||||||
let error = runtime
|
let error = runtime
|
||||||
.create_worker(task_request("missing bundle"))
|
.create_worker(task_request("missing backend"))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, RuntimeError::ConfigBundleMissing { .. }));
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
RuntimeError::ExecutionBackendUnavailable { .. }
|
||||||
|
));
|
||||||
assert!(runtime.list_workers().unwrap().is_empty());
|
assert!(runtime.list_workers().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1903,12 +1850,12 @@ mod tests {
|
||||||
));
|
));
|
||||||
let refreshed = runtime.worker_detail(&detail.worker_ref).unwrap();
|
let refreshed = runtime.worker_detail(&detail.worker_ref).unwrap();
|
||||||
assert_eq!(refreshed.execution.run_state, WorkerExecutionRunState::Busy);
|
assert_eq!(refreshed.execution.run_state, WorkerExecutionRunState::Busy);
|
||||||
assert_eq!(
|
#[cfg(feature = "ws-server")]
|
||||||
|
assert!(
|
||||||
runtime
|
runtime
|
||||||
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 1))
|
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.total_items,
|
.is_empty()
|
||||||
0
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1987,12 +1934,12 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ws-server")]
|
||||||
#[test]
|
#[test]
|
||||||
fn send_input_and_project_bounded_transcript() {
|
fn send_input_records_protocol_observations() {
|
||||||
let runtime = Runtime::with_execution_backend(
|
let runtime = Runtime::with_execution_backend(
|
||||||
RuntimeOptions {
|
RuntimeOptions {
|
||||||
limits: RuntimeLimits {
|
limits: RuntimeLimits {
|
||||||
max_transcript_projection_items: 2,
|
|
||||||
max_event_batch_items: 16,
|
max_event_batch_items: 16,
|
||||||
},
|
},
|
||||||
..RuntimeOptions::default()
|
..RuntimeOptions::default()
|
||||||
|
|
@ -2006,27 +1953,23 @@ mod tests {
|
||||||
let first = runtime
|
let first = runtime
|
||||||
.send_input(&detail.worker_ref, WorkerInput::user("hello"))
|
.send_input(&detail.worker_ref, WorkerInput::user("hello"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(first.transcript_sequence, 1);
|
|
||||||
runtime
|
runtime
|
||||||
.send_input(&detail.worker_ref, WorkerInput::system("note"))
|
.send_input(&detail.worker_ref, WorkerInput::system("note"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
runtime
|
|
||||||
.send_input(&detail.worker_ref, WorkerInput::user("again"))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let projection = runtime
|
let observations = runtime
|
||||||
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 2))
|
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(projection.total_items, 3);
|
assert_eq!(first.event_id, 3);
|
||||||
assert_eq!(projection.items.len(), 2);
|
assert_eq!(observations.len(), 2);
|
||||||
assert_eq!(projection.items[0].content, "hello");
|
assert!(matches!(
|
||||||
assert_eq!(projection.items[1].role, TranscriptRole::System);
|
observations[0].payload,
|
||||||
assert_eq!(projection.next_start, Some(2));
|
protocol::Event::UserMessage { .. }
|
||||||
|
));
|
||||||
let err = runtime
|
assert!(matches!(
|
||||||
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 3))
|
observations[1].payload,
|
||||||
.unwrap_err();
|
protocol::Event::SystemItem { .. }
|
||||||
assert!(matches!(err, RuntimeError::LimitTooLarge { .. }));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2191,7 +2134,7 @@ mod tests {
|
||||||
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
#[test]
|
#[test]
|
||||||
fn fs_store_restores_workers_events_and_transcripts() {
|
fn fs_store_restores_workers_events_and_protocol_observations() {
|
||||||
let root = fs_store_root("restore");
|
let root = fs_store_root("restore");
|
||||||
let runtime_id = RuntimeId::new("runtime-fs-authority").unwrap();
|
let runtime_id = RuntimeId::new("runtime-fs-authority").unwrap();
|
||||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||||
|
|
@ -2200,7 +2143,6 @@ mod tests {
|
||||||
runtime_id: Some(runtime_id.clone()),
|
runtime_id: Some(runtime_id.clone()),
|
||||||
display_name: Some("filesystem runtime".to_string()),
|
display_name: Some("filesystem runtime".to_string()),
|
||||||
limits: RuntimeLimits {
|
limits: RuntimeLimits {
|
||||||
max_transcript_projection_items: 2,
|
|
||||||
max_event_batch_items: 2,
|
max_event_batch_items: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -2257,14 +2199,21 @@ mod tests {
|
||||||
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
|
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
assert_eq!(restored_worker.transcript_len, 2);
|
#[cfg(feature = "ws-server")]
|
||||||
|
{
|
||||||
let projection = restored
|
let observations = restored
|
||||||
.transcript_projection(&worker.worker_ref, TranscriptQuery::new(0, 1))
|
.read_worker_observation_events(&worker.worker_ref, WorkerObservationCursor::zero())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(projection.total_items, 2);
|
assert_eq!(observations.len(), 2);
|
||||||
assert_eq!(projection.items[0].content, "first");
|
assert!(matches!(
|
||||||
assert_eq!(projection.next_start, Some(1));
|
observations[0].payload,
|
||||||
|
protocol::Event::UserMessage { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
observations[1].payload,
|
||||||
|
protocol::Event::SystemItem { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let cursor = restored.event_cursor_from_start().unwrap();
|
let cursor = restored.event_cursor_from_start().unwrap();
|
||||||
let batch = restored.read_events(&cursor, 2).unwrap();
|
let batch = restored.read_events(&cursor, 2).unwrap();
|
||||||
|
|
@ -2275,11 +2224,6 @@ mod tests {
|
||||||
|
|
||||||
let direct_events = store.read_events(&cursor, 2, 2).unwrap();
|
let direct_events = store.read_events(&cursor, 2, 2).unwrap();
|
||||||
assert_eq!(direct_events.events, batch.events);
|
assert_eq!(direct_events.events, batch.events);
|
||||||
let direct_transcript = store
|
|
||||||
.read_transcript(&worker.worker_ref, TranscriptQuery::new(1, 1), 2)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(direct_transcript.items[0].content, "second");
|
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
{
|
{
|
||||||
let observation = restored
|
let observation = restored
|
||||||
|
|
@ -2290,12 +2234,12 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(observation.sequence, 1);
|
assert_eq!(observation.sequence, 3);
|
||||||
let observations = restored
|
let observations = restored
|
||||||
.read_worker_observation_events(&worker.worker_ref, WorkerObservationCursor::zero())
|
.read_worker_observation_events(&worker.worker_ref, WorkerObservationCursor::zero())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(observations.len(), 1);
|
assert_eq!(observations.len(), 3);
|
||||||
assert_eq!(observations[0].cursor, observation.cursor);
|
assert_eq!(observations[2].cursor, observation.cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(root);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
|
@ -2354,14 +2298,21 @@ mod tests {
|
||||||
worker_dirs.sort_by_key(|entry| entry.path());
|
worker_dirs.sort_by_key(|entry| entry.path());
|
||||||
std::fs::remove_file(worker_dirs[0].path().join("worker.json")).unwrap();
|
std::fs::remove_file(worker_dirs[0].path().join("worker.json")).unwrap();
|
||||||
drop(missing_runtime);
|
drop(missing_runtime);
|
||||||
let err = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
let loaded = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
|
||||||
root: missing_root.clone(),
|
root: missing_root.clone(),
|
||||||
runtime_id: Some(missing_runtime_id),
|
runtime_id: Some(missing_runtime_id),
|
||||||
display_name: None,
|
display_name: None,
|
||||||
limits: RuntimeLimits::default(),
|
limits: RuntimeLimits::default(),
|
||||||
})
|
})
|
||||||
.unwrap_err();
|
.expect("invalid worker snapshot should not make runtime store unreadable");
|
||||||
assert!(matches!(err, RuntimeError::StoreMissing { .. }));
|
assert!(loaded.list_workers().unwrap().is_empty());
|
||||||
|
assert!(
|
||||||
|
loaded
|
||||||
|
.diagnostics()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic.code == "worker_snapshot_ignored")
|
||||||
|
);
|
||||||
let _ = std::fs::remove_dir_all(missing_root);
|
let _ = std::fs::remove_dir_all(missing_root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,24 @@
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config_bundle::verified_profile_source_archive;
|
use crate::catalog::{
|
||||||
|
ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, WorkingDirectoryRequest,
|
||||||
|
WorkingDirectoryStatus,
|
||||||
|
};
|
||||||
use crate::execution::{
|
use crate::execution::{
|
||||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
||||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||||
};
|
};
|
||||||
use crate::interaction::{WorkerInput, WorkerInputKind};
|
use crate::interaction::{WorkerInput, WorkerInputKind};
|
||||||
use crate::resource::{
|
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
|
||||||
BackendResourceClient, BackendResourceError, ProfileSourceArchiveCache,
|
use crate::working_directory::{
|
||||||
build_profile_source_archive_fetch_request, profile_source_archive_from_response,
|
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
|
||||||
};
|
};
|
||||||
use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryMaterializer};
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use manifest::paths;
|
use manifest::paths;
|
||||||
use protocol::{Method, Segment, WorkerStatus};
|
use protocol::{Method, Segment, WorkerStatus};
|
||||||
|
|
@ -33,7 +35,10 @@ use session_store::{CombinedStore, FsWorkerStore};
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use worker::{Worker, WorkerController, WorkerHandle};
|
use worker::{
|
||||||
|
Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext,
|
||||||
|
WorkspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||||
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
|
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
@ -135,7 +140,11 @@ impl ProfileRuntimeWorkerFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_worker_name(request: &WorkerExecutionSpawnRequest) -> String {
|
fn runtime_worker_name(request: &WorkerExecutionSpawnRequest) -> String {
|
||||||
request.worker_ref.worker_id.to_string()
|
format!(
|
||||||
|
"runtime-{}-{}",
|
||||||
|
sanitize_worker_name_component(request.worker_ref.runtime_id.as_str()),
|
||||||
|
request.worker_ref.worker_id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_profile_value(
|
fn runtime_profile_value(
|
||||||
|
|
@ -167,54 +176,151 @@ impl ProfileRuntimeWorkerFactory {
|
||||||
}
|
}
|
||||||
async fn resolve_profile_source_archive(
|
async fn resolve_profile_source_archive(
|
||||||
&self,
|
&self,
|
||||||
bundle: &crate::config_bundle::ConfigBundle,
|
source: &ProfileSourceArchiveSource,
|
||||||
request: &WorkerExecutionSpawnRequest,
|
|
||||||
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
||||||
if let Some(archive) = verified_profile_source_archive(bundle)
|
match source {
|
||||||
.map_err(|err| format!("failed to verify profile source archive: {err}"))?
|
ProfileSourceArchiveSource::Embedded { archive } => archive
|
||||||
{
|
|
||||||
return Ok(archive);
|
|
||||||
}
|
|
||||||
let handle = bundle
|
|
||||||
.profile_source_archive_handle
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"config bundle {} does not contain a ProfileSourceArchive resource handle",
|
|
||||||
bundle.metadata.id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let client = self.resource_client.as_ref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"config bundle {} requires a Backend resource client for profile source archive fetch",
|
|
||||||
bundle.metadata.id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let fetch_request = build_profile_source_archive_fetch_request(
|
|
||||||
handle.clone(),
|
|
||||||
&request.worker_ref.runtime_id,
|
|
||||||
Some(&request.worker_ref.worker_id),
|
|
||||||
);
|
|
||||||
let response = client
|
|
||||||
.fetch_resource(fetch_request)
|
|
||||||
.await
|
|
||||||
.map_err(format_backend_resource_error)?;
|
|
||||||
let fetched_archive = profile_source_archive_from_response(&handle, response)
|
|
||||||
.map_err(format_backend_resource_error)?;
|
|
||||||
if let Some(cached) = self.profile_archive_cache.get(&handle.digest) {
|
|
||||||
return cached
|
|
||||||
.verify()
|
.verify()
|
||||||
.map_err(|err| format!("failed to verify cached profile source archive: {err}"));
|
.map_err(|err| format!("failed to verify embedded profile source archive: {err}")),
|
||||||
|
ProfileSourceArchiveSource::Http { location } => {
|
||||||
|
self.fetch_profile_source_archive(location).await
|
||||||
}
|
}
|
||||||
self.profile_archive_cache.insert(fetched_archive.clone());
|
}
|
||||||
fetched_archive
|
}
|
||||||
|
|
||||||
|
async fn fetch_profile_source_archive(
|
||||||
|
&self,
|
||||||
|
location: &ProfileSourceArchiveHttpRef,
|
||||||
|
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
|
||||||
|
if let Some(cached) = self.profile_archive_cache.get(&location.archive.digest) {
|
||||||
|
let response =
|
||||||
|
fetch_profile_source_archive_http(location, Some(&location.archive.digest)).await?;
|
||||||
|
if let Some(fetched) = response {
|
||||||
|
self.profile_archive_cache.insert(fetched.clone());
|
||||||
|
fetched.verify().map_err(|err| {
|
||||||
|
format!("failed to verify fetched profile source archive: {err}")
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
cached
|
||||||
|
.verify()
|
||||||
|
.map_err(|err| format!("failed to verify cached profile source archive: {err}"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let archive = fetch_profile_source_archive_http(location, None)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"profile source archive HTTP revalidation returned 304 without a cached archive"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
self.profile_archive_cache.insert(archive.clone());
|
||||||
|
archive
|
||||||
.verify()
|
.verify()
|
||||||
.map_err(|err| format!("failed to verify fetched profile source archive: {err}"))
|
.map_err(|err| format!("failed to verify fetched profile source archive: {err}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_backend_resource_error(error: BackendResourceError) -> String {
|
fn sanitize_worker_name_component(value: &str) -> String {
|
||||||
format!("backend resource fetch failed: {error}")
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum RuntimeWorkspaceBackendRef {
|
||||||
|
None,
|
||||||
|
LocalFilesystem { root: PathBuf },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeWorkspaceBackendRef {
|
||||||
|
fn from_working_directory(binding: Option<&WorkingDirectoryBinding>) -> Self {
|
||||||
|
match binding {
|
||||||
|
Some(binding) => Self::LocalFilesystem {
|
||||||
|
root: binding.root().to_path_buf(),
|
||||||
|
},
|
||||||
|
None => Self::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_context(&self) -> WorkerWorkspaceContext {
|
||||||
|
match self {
|
||||||
|
Self::None => WorkerWorkspaceContext::no_workspace(),
|
||||||
|
Self::LocalFilesystem { root } => local_workspace_context(root),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_workspace_context(root: &Path) -> WorkerWorkspaceContext {
|
||||||
|
WorkerWorkspaceContext::local_filesystem(read_workspace_id_hint(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_workspace_id_hint(root: &Path) -> Option<WorkspaceId> {
|
||||||
|
let contents = std::fs::read_to_string(root.join(".yoi/workspace.toml")).ok()?;
|
||||||
|
let value = toml::from_str::<toml::Value>(&contents).ok()?;
|
||||||
|
let id = value.get("id")?.as_str()?.to_string();
|
||||||
|
WorkspaceId::new(id).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "http-server")]
|
||||||
|
async fn fetch_profile_source_archive_http(
|
||||||
|
location: &ProfileSourceArchiveHttpRef,
|
||||||
|
cached_digest: Option<&str>,
|
||||||
|
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut request = client.get(&location.url);
|
||||||
|
if cached_digest == Some(location.archive.digest.as_str()) {
|
||||||
|
if let Some(etag) = location.etag.as_deref() {
|
||||||
|
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("failed to fetch profile source archive: {err}"))?;
|
||||||
|
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
return Err(format!(
|
||||||
|
"profile source archive fetch failed with HTTP {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("failed to read profile source archive response: {err}"))?
|
||||||
|
.to_vec();
|
||||||
|
let archive = crate::profile_archive::ProfileSourceArchive {
|
||||||
|
reference: location.archive.clone(),
|
||||||
|
content: bytes,
|
||||||
|
};
|
||||||
|
if archive.content.len() as u64 != archive.reference.size_bytes {
|
||||||
|
return Err(format!(
|
||||||
|
"profile source archive size mismatch: expected {}, got {}",
|
||||||
|
archive.reference.size_bytes,
|
||||||
|
archive.content.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(archive))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "http-server"))]
|
||||||
|
async fn fetch_profile_source_archive_http(
|
||||||
|
_location: &ProfileSourceArchiveHttpRef,
|
||||||
|
_cached_digest: Option<&str>,
|
||||||
|
) -> Result<Option<crate::profile_archive::ProfileSourceArchive>, String> {
|
||||||
|
Err(
|
||||||
|
"HTTP profile source archive fetch requires the worker-runtime http-server feature"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -230,16 +336,24 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|binding| binding.root().to_path_buf())
|
.map(|binding| binding.root().to_path_buf())
|
||||||
.unwrap_or_else(|| self.profile_base_dir.clone());
|
.unwrap_or_else(|| self.profile_base_dir.clone());
|
||||||
let cwd = request
|
let filesystem_authority = request
|
||||||
.working_directory
|
.working_directory
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|binding| binding.cwd().to_path_buf())
|
.map(|binding| {
|
||||||
.unwrap_or_else(|| self.cwd.clone());
|
WorkerFilesystemAuthority::local(
|
||||||
let (mut manifest, loader) = if let Some(bundle) = request.config_bundle.as_ref() {
|
binding.root().to_path_buf(),
|
||||||
|
binding.cwd().to_path_buf(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(WorkerFilesystemAuthority::None);
|
||||||
|
let workspace_backend_ref =
|
||||||
|
RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref());
|
||||||
|
let workspace_context = workspace_backend_ref.worker_context();
|
||||||
let selector = profile.as_deref().unwrap_or("builtin:default");
|
let selector = profile.as_deref().unwrap_or("builtin:default");
|
||||||
let archive = self
|
let archive = self
|
||||||
.resolve_profile_source_archive(bundle, &request)
|
.resolve_profile_source_archive(&request.request.profile_source)
|
||||||
.await?;
|
.await?;
|
||||||
|
let (mut manifest, loader) = {
|
||||||
let manifest = archive
|
let manifest = archive
|
||||||
.resolve_profile(selector, &worker_root, &worker_name)
|
.resolve_profile(selector, &worker_root, &worker_name)
|
||||||
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
||||||
|
|
@ -248,15 +362,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||||
&worker_root,
|
&worker_root,
|
||||||
&worker_name,
|
&worker_name,
|
||||||
)?
|
)?
|
||||||
} else {
|
|
||||||
// Compatibility/debug fallback for direct CLI tests. Normal Browser/Backend launch
|
|
||||||
// supplies a ProfileSourceArchive inside the Runtime config bundle and must not use
|
|
||||||
// Runtime-local filesystem profile discovery.
|
|
||||||
worker::entrypoint::resolve_runtime_profile_manifest(
|
|
||||||
profile.as_deref(),
|
|
||||||
&self.profile_base_dir,
|
|
||||||
&worker_name,
|
|
||||||
)?
|
|
||||||
};
|
};
|
||||||
manifest.worker.name = worker_name;
|
manifest.worker.name = worker_name;
|
||||||
|
|
||||||
|
|
@ -276,7 +381,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||||
})?;
|
})?;
|
||||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||||
|
|
||||||
let worker = Worker::from_manifest_with_context(manifest, store, loader, worker_root, cwd)
|
let worker = Worker::from_manifest_with_context(
|
||||||
|
manifest,
|
||||||
|
store,
|
||||||
|
loader,
|
||||||
|
workspace_context,
|
||||||
|
filesystem_authority,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
|
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
|
||||||
|
|
||||||
|
|
@ -441,6 +552,52 @@ where
|
||||||
&self.backend_id
|
&self.backend_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_working_directory(
|
||||||
|
&self,
|
||||||
|
request: &WorkingDirectoryRequest,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
let Some(materializer) = self.working_directory_materializer.as_ref() else {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_materializer_unavailable",
|
||||||
|
"working directory materialization requested, but no materializer is configured for this runtime backend",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(materializer.create(request)?.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
|
||||||
|
self.working_directory_materializer
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|materializer| materializer.list_working_directories().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
let Some(materializer) = self.working_directory_materializer.as_ref() else {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_materializer_unavailable",
|
||||||
|
"working directory lookup requested, but no materializer is configured for this runtime backend",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
materializer.working_directory_status(working_directory_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_working_directory(
|
||||||
|
&self,
|
||||||
|
working_directory_id: &str,
|
||||||
|
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||||
|
let Some(materializer) = self.working_directory_materializer.as_ref() else {
|
||||||
|
return Err(WorkingDirectoryDiagnostic::rejected(
|
||||||
|
"working_directory_materializer_unavailable",
|
||||||
|
"working directory cleanup requested, but no materializer is configured for this runtime backend",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
materializer.cleanup_working_directory(working_directory_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||||
if self
|
if self
|
||||||
.workers
|
.workers
|
||||||
|
|
@ -701,7 +858,7 @@ where
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
@ -715,7 +872,7 @@ mod tests {
|
||||||
use crate::execution::WorkerExecutionContext;
|
use crate::execution::WorkerExecutionContext;
|
||||||
use crate::identity::RuntimeId;
|
use crate::identity::RuntimeId;
|
||||||
use crate::management::RuntimeOptions;
|
use crate::management::RuntimeOptions;
|
||||||
use crate::observation::{TranscriptQuery, TranscriptRole};
|
use crate::observation::WorkerObservationCursor;
|
||||||
use crate::working_directory::LocalGitWorktreeMaterializer;
|
use crate::working_directory::LocalGitWorktreeMaterializer;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
|
|
@ -797,18 +954,31 @@ mod tests {
|
||||||
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
|
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
|
||||||
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
|
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
|
||||||
);
|
);
|
||||||
let cwd = request
|
let filesystem_authority = request
|
||||||
.working_directory
|
.working_directory
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|binding| binding.cwd().to_path_buf())
|
.map(|binding| {
|
||||||
.unwrap_or_else(|| self.cwd.clone());
|
let cwd = binding.cwd().to_path_buf();
|
||||||
self.observed_cwds.lock().unwrap().push(cwd.clone());
|
self.observed_cwds.lock().unwrap().push(cwd.clone());
|
||||||
let scope = Scope::writable(&cwd).map_err(|err| err.to_string())?;
|
WorkerFilesystemAuthority::local(binding.root().to_path_buf(), cwd)
|
||||||
|
})
|
||||||
|
.unwrap_or(WorkerFilesystemAuthority::None);
|
||||||
|
let scope_root = request
|
||||||
|
.working_directory
|
||||||
|
.as_ref()
|
||||||
|
.map(|binding| binding.root().to_path_buf())
|
||||||
|
.unwrap_or_else(|| self.cwd.clone());
|
||||||
|
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_working_directory(
|
||||||
|
request.working_directory.as_ref(),
|
||||||
|
);
|
||||||
|
let workspace_context = workspace_backend_ref.worker_context();
|
||||||
|
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
|
||||||
let worker = Worker::new(
|
let worker = Worker::new(
|
||||||
manifest,
|
manifest,
|
||||||
Engine::new(self.client.clone()),
|
Engine::new(self.client.clone()),
|
||||||
store,
|
store,
|
||||||
cwd,
|
workspace_context,
|
||||||
|
filesystem_authority,
|
||||||
scope,
|
scope,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -820,6 +990,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn core_filesystem_tool_names() -> BTreeSet<&'static str> {
|
||||||
|
["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn captured_tool_names(client: &MockClient, index: usize) -> BTreeSet<String> {
|
||||||
|
client.captured.lock().unwrap()[index]
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn simple_text_events() -> Vec<LlmEvent> {
|
fn simple_text_events() -> Vec<LlmEvent> {
|
||||||
vec![
|
vec![
|
||||||
LlmEvent::text_block_start(0),
|
LlmEvent::text_block_start(0),
|
||||||
|
|
@ -850,7 +1034,7 @@ mod tests {
|
||||||
label: Some("adapter-test".to_string()),
|
label: Some("adapter-test".to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
declarations: Vec::new(),
|
||||||
profile_source_archive: None,
|
profile_source_archive: Some(sample_profile_archive()),
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
.with_computed_digest()
|
.with_computed_digest()
|
||||||
|
|
@ -879,110 +1063,17 @@ mod tests {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_for_archive(
|
|
||||||
archive: &crate::profile_archive::ProfileSourceArchive,
|
|
||||||
) -> crate::resource::BackendResourceHandle {
|
|
||||||
crate::resource::BackendResourceHandle {
|
|
||||||
kind: crate::resource::BackendResourceKind::ProfileSourceArchive,
|
|
||||||
workspace_id: "workspace-test".to_string(),
|
|
||||||
scope_id: Some("workspace-profile-source".to_string()),
|
|
||||||
runtime_id: Some("runtime-test".to_string()),
|
|
||||||
worker_id: Some("worker-test".to_string()),
|
|
||||||
resource_id: archive.reference.id.clone(),
|
|
||||||
digest: archive.reference.digest.clone(),
|
|
||||||
operation: crate::resource::BackendResourceOperation::FetchArchive,
|
|
||||||
expires_at_unix_seconds: 4_102_444_800,
|
|
||||||
nonce: "nonce-test".to_string(),
|
|
||||||
revision: archive.reference.digest.clone(),
|
|
||||||
generation: Some(1),
|
|
||||||
max_bytes: crate::resource::DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
|
|
||||||
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
|
|
||||||
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
|
|
||||||
audit_correlation_id: "audit-test".to_string(),
|
|
||||||
profile_source_graph: Some(archive.reference.source_graph.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn response_for_archive(
|
|
||||||
handle: &crate::resource::BackendResourceHandle,
|
|
||||||
archive: &crate::profile_archive::ProfileSourceArchive,
|
|
||||||
) -> crate::resource::BackendResourceFetchResponse {
|
|
||||||
crate::resource::BackendResourceFetchResponse {
|
|
||||||
kind: crate::resource::BackendResourceKind::ProfileSourceArchive,
|
|
||||||
resource_id: handle.resource_id.clone(),
|
|
||||||
digest: handle.digest.clone(),
|
|
||||||
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
|
|
||||||
bytes: archive.content.clone(),
|
|
||||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct SequencedResourceClient {
|
|
||||||
responses: Arc<
|
|
||||||
Mutex<
|
|
||||||
VecDeque<
|
|
||||||
Result<
|
|
||||||
crate::resource::BackendResourceFetchResponse,
|
|
||||||
crate::resource::BackendResourceError,
|
|
||||||
>,
|
|
||||||
>,
|
|
||||||
>,
|
|
||||||
>,
|
|
||||||
call_count: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl crate::resource::BackendResourceClient for SequencedResourceClient {
|
|
||||||
async fn fetch_resource(
|
|
||||||
&self,
|
|
||||||
_request: crate::resource::BackendResourceFetchRequest,
|
|
||||||
) -> Result<
|
|
||||||
crate::resource::BackendResourceFetchResponse,
|
|
||||||
crate::resource::BackendResourceError,
|
|
||||||
> {
|
|
||||||
self.call_count.fetch_add(1, Ordering::SeqCst);
|
|
||||||
self.responses
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.pop_front()
|
|
||||||
.expect("missing sequenced resource response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_request_with_bundle(
|
|
||||||
bundle: crate::config_bundle::ConfigBundle,
|
|
||||||
) -> WorkerExecutionSpawnRequest {
|
|
||||||
let worker_ref = crate::identity::WorkerRef::new(
|
|
||||||
RuntimeId::new("runtime-test").unwrap(),
|
|
||||||
crate::identity::WorkerId::new("worker-test").unwrap(),
|
|
||||||
);
|
|
||||||
WorkerExecutionSpawnRequest {
|
|
||||||
worker_ref: worker_ref.clone(),
|
|
||||||
request: CreateWorkerRequest {
|
|
||||||
profile: ProfileSelector::RuntimeDefault,
|
|
||||||
config_bundle: ConfigBundleRef {
|
|
||||||
id: bundle.metadata.id.clone(),
|
|
||||||
digest: bundle.metadata.digest.clone(),
|
|
||||||
},
|
|
||||||
initial_input: None,
|
|
||||||
working_directory_request: None,
|
|
||||||
working_directory: None,
|
|
||||||
},
|
|
||||||
context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))),
|
|
||||||
working_directory: None,
|
|
||||||
config_bundle: Some(bundle),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_request(_name: &str) -> CreateWorkerRequest {
|
fn create_request(_name: &str) -> CreateWorkerRequest {
|
||||||
let bundle = test_bundle();
|
let bundle = test_bundle();
|
||||||
CreateWorkerRequest {
|
CreateWorkerRequest {
|
||||||
profile: ProfileSelector::RuntimeDefault,
|
profile: ProfileSelector::RuntimeDefault,
|
||||||
config_bundle: ConfigBundleRef {
|
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
|
||||||
|
archive: bundle.profile_source_archive.clone().unwrap(),
|
||||||
|
},
|
||||||
|
config_bundle: Some(ConfigBundleRef {
|
||||||
id: bundle.metadata.id,
|
id: bundle.metadata.id,
|
||||||
digest: bundle.metadata.digest,
|
digest: bundle.metadata.digest,
|
||||||
},
|
}),
|
||||||
initial_input: None,
|
initial_input: None,
|
||||||
working_directory_request: None,
|
working_directory_request: None,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
|
|
@ -1024,38 +1115,46 @@ mod tests {
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_worker_name_is_namespaced_by_runtime_id() {
|
||||||
|
let runtime_id = RuntimeId::new("arc:remote".to_string()).unwrap();
|
||||||
|
let worker_ref = crate::identity::WorkerRef::new(
|
||||||
|
runtime_id,
|
||||||
|
crate::identity::WorkerId::new("worker-00000001".to_string()).unwrap(),
|
||||||
|
);
|
||||||
|
let request = WorkerExecutionSpawnRequest {
|
||||||
|
worker_ref: worker_ref.clone(),
|
||||||
|
request: create_request("worker-00000001"),
|
||||||
|
context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))),
|
||||||
|
working_directory: None,
|
||||||
|
config_bundle: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||||
|
"runtime-arc-remote-worker-00000001"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
|
||||||
|
"worker-00000001"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cached_profile_archive_still_requires_backend_authorization() {
|
async fn embedded_profile_source_archive_does_not_require_backend_resource_fetch() {
|
||||||
let archive = sample_profile_archive();
|
let factory = ProfileRuntimeWorkerFactory::new(tempfile::tempdir().unwrap().path());
|
||||||
let handle = handle_for_archive(&archive);
|
let bundle = test_bundle();
|
||||||
let call_count = Arc::new(AtomicUsize::new(0));
|
let source = crate::catalog::ProfileSourceArchiveSource::Embedded {
|
||||||
let client = SequencedResourceClient {
|
archive: bundle.profile_source_archive.clone().unwrap(),
|
||||||
responses: Arc::new(Mutex::new(VecDeque::from([
|
|
||||||
Ok(response_for_archive(&handle, &archive)),
|
|
||||||
Err(crate::resource::BackendResourceError::Expired),
|
|
||||||
]))),
|
|
||||||
call_count: call_count.clone(),
|
|
||||||
};
|
};
|
||||||
let factory = ProfileRuntimeWorkerFactory::new(tempfile::tempdir().unwrap().path())
|
|
||||||
.with_resource_client(Arc::new(client));
|
|
||||||
let mut bundle = test_bundle();
|
|
||||||
bundle.profile_source_archive_handle = Some(handle);
|
|
||||||
bundle = bundle.with_computed_digest();
|
|
||||||
|
|
||||||
factory
|
factory
|
||||||
.resolve_profile_source_archive(&bundle, &spawn_request_with_bundle(bundle.clone()))
|
.resolve_profile_source_archive(&source)
|
||||||
.await
|
.await
|
||||||
.expect("first fetch should authorize and cache archive");
|
.expect("embedded archive should resolve without Backend resource client");
|
||||||
let err = factory
|
|
||||||
.resolve_profile_source_archive(&bundle, &spawn_request_with_bundle(bundle.clone()))
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(err.contains("expired"), "unexpected error: {err}");
|
|
||||||
assert_eq!(call_count.load(Ordering::SeqCst), 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1089,7 +1188,7 @@ mod tests {
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
store_dir: store.path().join("sessions"),
|
store_dir: store.path().join("sessions"),
|
||||||
worker_metadata_dir: store.path().join("workers"),
|
worker_metadata_dir: store.path().join("workers"),
|
||||||
observed_cwds,
|
observed_cwds: observed_cwds.clone(),
|
||||||
};
|
};
|
||||||
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
|
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
|
||||||
let runtime = EmbeddedRuntime::with_execution_backend(
|
let runtime = EmbeddedRuntime::with_execution_backend(
|
||||||
|
|
@ -1109,27 +1208,35 @@ mod tests {
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
loop {
|
loop {
|
||||||
let projection = runtime
|
let observations = runtime
|
||||||
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 10))
|
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
if projection.items.iter().any(|item| {
|
if observations.iter().any(|event| {
|
||||||
item.role == TranscriptRole::Assistant && item.content == "hello from worker"
|
matches!(
|
||||||
|
&event.payload,
|
||||||
|
protocol::Event::TextDone { text } if text == "hello from worker"
|
||||||
|
)
|
||||||
}) {
|
}) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
std::time::Instant::now() < deadline,
|
std::time::Instant::now() < deadline,
|
||||||
"timed out waiting for assistant transcript projection"
|
"timed out waiting for assistant protocol observation"
|
||||||
);
|
);
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(client.captured.lock().unwrap().len(), 1);
|
assert_eq!(client.captured.lock().unwrap().len(), 1);
|
||||||
|
assert!(observed_cwds.lock().unwrap().is_empty());
|
||||||
|
let names = captured_tool_names(&client, 0);
|
||||||
|
for forbidden in core_filesystem_tool_names() {
|
||||||
|
assert!(
|
||||||
|
!names.contains(forbidden),
|
||||||
|
"no-workdir Worker unexpectedly exposed {forbidden}; tools={names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
let observations = runtime
|
let observations = runtime
|
||||||
.read_worker_observation_events(
|
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
|
||||||
&detail.worker_ref,
|
|
||||||
crate::observation::WorkerObservationCursor::zero(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
observations
|
observations
|
||||||
|
|
@ -1146,7 +1253,7 @@ mod tests {
|
||||||
let store = tempfile::tempdir().unwrap();
|
let store = tempfile::tempdir().unwrap();
|
||||||
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
|
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
|
||||||
let factory = MockFactory {
|
let factory = MockFactory {
|
||||||
client,
|
client: client.clone(),
|
||||||
runtime_base: runtime_base.path().to_path_buf(),
|
runtime_base: runtime_base.path().to_path_buf(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
store_dir: store.path().join("sessions"),
|
store_dir: store.path().join("sessions"),
|
||||||
|
|
@ -1171,6 +1278,24 @@ mod tests {
|
||||||
request.working_directory_request = Some(working_directory_request(repo.path()));
|
request.working_directory_request = Some(working_directory_request(repo.path()));
|
||||||
|
|
||||||
let detail = runtime.create_worker(request).unwrap();
|
let detail = runtime.create_worker(request).unwrap();
|
||||||
|
runtime
|
||||||
|
.send_input(&detail.worker_ref, WorkerInput::user("inspect tools"))
|
||||||
|
.unwrap();
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while client.captured.lock().unwrap().is_empty() {
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"timed out waiting for materialized-worker request"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
let names = captured_tool_names(&client, 0);
|
||||||
|
for expected in core_filesystem_tool_names() {
|
||||||
|
assert!(
|
||||||
|
names.contains(expected),
|
||||||
|
"local Worker did not expose {expected}; tools={names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
assert!(detail.execution.working_directory.is_some());
|
assert!(detail.execution.working_directory.is_some());
|
||||||
let cwds = observed_cwds.lock().unwrap();
|
let cwds = observed_cwds.lock().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ impl WorkingDirectory {
|
||||||
cleanup_target: Some(self.cleanup_target.clone()),
|
cleanup_target: Some(self.cleanup_target.clone()),
|
||||||
cleanup_policy: Some(self.cleanup_policy.clone()),
|
cleanup_policy: Some(self.cleanup_policy.clone()),
|
||||||
status: self.status.clone(),
|
status: self.status.clone(),
|
||||||
|
management_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +96,10 @@ pub struct WorkingDirectoryDiagnostic {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkingDirectoryDiagnostic {
|
impl WorkingDirectoryDiagnostic {
|
||||||
|
pub fn rejected(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(code, message)
|
||||||
|
}
|
||||||
|
|
||||||
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
|
|
@ -391,7 +396,10 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
&self,
|
&self,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||||
let working_directory_id = next_working_directory_id(&request.repository.id);
|
let working_directory_id = request
|
||||||
|
.backend_workdir_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| next_working_directory_id(&request.repository.id));
|
||||||
self.materialize_with_working_directory_id(
|
self.materialize_with_working_directory_id(
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
request,
|
request,
|
||||||
|
|
@ -715,6 +723,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,9 @@ impl WorkerController {
|
||||||
manifest_toml.clone(),
|
manifest_toml.clone(),
|
||||||
greeting,
|
greeting,
|
||||||
));
|
));
|
||||||
|
if let Some(fs_for_view) = fs_for_view {
|
||||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
||||||
|
}
|
||||||
shared_state.set_workflows(
|
shared_state.set_workflows(
|
||||||
worker
|
worker
|
||||||
.workflow_completions()
|
.workflow_completions()
|
||||||
|
|
@ -520,15 +522,17 @@ fn install_ticket_event_companion_notify_hook<C, St>(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(companion_worker_name) = companion_worker_name_for_workspace(worker.workspace_root())
|
let Some(local) = worker.local_working_directory() else {
|
||||||
else {
|
return;
|
||||||
|
};
|
||||||
|
let Some(companion_worker_name) = companion_worker_name_for_workspace(&local.root) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if companion_worker_name == worker.manifest().worker.name {
|
if companion_worker_name == worker.manifest().worker.name {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(ticket_config) = TicketConfig::load_workspace(worker.cwd()) else {
|
let Ok(ticket_config) = TicketConfig::load_workspace(&local.cwd) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let backend_root = ticket_config.backend_root().to_path_buf();
|
let backend_root = ticket_config.backend_root().to_path_buf();
|
||||||
|
|
@ -540,7 +544,7 @@ fn install_ticket_event_companion_notify_hook<C, St>(
|
||||||
worker.worker_metadata_store(),
|
worker.worker_metadata_store(),
|
||||||
worker.manifest().worker.name.clone(),
|
worker.manifest().worker.name.clone(),
|
||||||
runtime_base,
|
runtime_base,
|
||||||
worker.cwd().to_path_buf(),
|
Some(local.cwd.clone()),
|
||||||
spawned_registry,
|
spawned_registry,
|
||||||
);
|
);
|
||||||
match discovery.ensure_existing_peer(&companion_worker_name) {
|
match discovery.ensure_existing_peer(&companion_worker_name) {
|
||||||
|
|
@ -589,7 +593,7 @@ async fn register_worker_tools<C, St>(
|
||||||
spawner_socket: PathBuf,
|
spawner_socket: PathBuf,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
) -> std::io::Result<tools::ScopedFs>
|
) -> std::io::Result<Option<tools::ScopedFs>>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + 'static,
|
St: Store + WorkerMetadataStore + Clone + 'static,
|
||||||
|
|
@ -597,8 +601,8 @@ where
|
||||||
// Worker-immutable snapshots taken before the mutable worker borrow
|
// Worker-immutable snapshots taken before the mutable worker borrow
|
||||||
// below so the worker borrow doesn't conflict with reads on `worker`.
|
// below so the worker borrow doesn't conflict with reads on `worker`.
|
||||||
let scope_handle = worker.scope().clone();
|
let scope_handle = worker.scope().clone();
|
||||||
let cwd = worker.cwd().to_path_buf();
|
let local_filesystem = worker.local_working_directory().cloned();
|
||||||
let workspace_root = worker.workspace_root().to_path_buf();
|
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
|
||||||
let task_feature = worker.task_feature();
|
let task_feature = worker.task_feature();
|
||||||
let session_id_for_usage = worker.segment_id().to_string();
|
let session_id_for_usage = worker.segment_id().to_string();
|
||||||
let memory_config = worker.manifest().memory.clone();
|
let memory_config = worker.manifest().memory.clone();
|
||||||
|
|
@ -611,16 +615,12 @@ where
|
||||||
let worker_metadata_store = worker.store().clone();
|
let worker_metadata_store = worker.store().clone();
|
||||||
let self_parent_socket = worker.callback_socket().cloned();
|
let self_parent_socket = worker.callback_socket().cloned();
|
||||||
|
|
||||||
// The Worker's SharedScope (already augmented with the bash-output
|
// The Worker's SharedScope is the single source of truth for every
|
||||||
// Read rule by the caller) is the single source of truth — every
|
// ScopedFs when local filesystem authority exists. No-workdir Workers
|
||||||
// ScopedFs (builtin tools, fs_view, compact worker) reads from it,
|
// deliberately skip constructing/registering filesystem and Bash tools.
|
||||||
// and any future scope mutation (SpawnWorker-style revoke, future
|
let (fs_for_view, tracker) = if let Some(local) = local_filesystem.as_ref() {
|
||||||
// GrantScope) propagates through it.
|
let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), local.cwd.clone());
|
||||||
let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), cwd.clone());
|
|
||||||
let tracker = tools::Tracker::new();
|
let tracker = tools::Tracker::new();
|
||||||
// Same ScopedFs also powers the IPC `ListCompletions` query — keep
|
|
||||||
// a clone for the FS view we attach below, since the tools consume
|
|
||||||
// `fs` itself.
|
|
||||||
let fs_for_view = fs.clone();
|
let fs_for_view = fs.clone();
|
||||||
worker
|
worker
|
||||||
.engine_mut()
|
.engine_mut()
|
||||||
|
|
@ -629,6 +629,10 @@ where
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
));
|
));
|
||||||
|
(Some(fs_for_view), Some(tracker))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
if feature_config.web.enabled {
|
if feature_config.web.enabled {
|
||||||
worker
|
worker
|
||||||
.engine_mut()
|
.engine_mut()
|
||||||
|
|
@ -649,11 +653,20 @@ where
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Ticket tools are typed operations over the currently checked-out work
|
// Ticket tools are typed operations over the currently checked-out work
|
||||||
// tree. Use the Worker cwd rather than the runtime workspace root so a
|
// tree. They require explicit local filesystem authority and must not
|
||||||
// dedicated Orchestrator worktree gets its own `.yoi/tickets` backend.
|
// use workspace identity as a cwd fallback.
|
||||||
|
let ticket_cwd = local_filesystem
|
||||||
|
.as_ref()
|
||||||
|
.map(|local| &local.cwd)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"ticket tools require local Worker filesystem authority",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
feature_registry.add_module(
|
feature_registry.add_module(
|
||||||
crate::feature::builtin::ticket::ticket_tools_feature_with_options(
|
crate::feature::builtin::ticket::ticket_tools_feature_with_options(
|
||||||
&cwd,
|
ticket_cwd,
|
||||||
feature_config.ticket.enabled.then_some(ticket_access),
|
feature_config.ticket.enabled.then_some(ticket_access),
|
||||||
feature_config.ticket_orchestration.enabled,
|
feature_config.ticket_orchestration.enabled,
|
||||||
),
|
),
|
||||||
|
|
@ -665,11 +678,13 @@ where
|
||||||
) {
|
) {
|
||||||
feature_registry = feature_registry.with_module(module);
|
feature_registry = feature_registry.with_module(module);
|
||||||
}
|
}
|
||||||
|
if let Some(workspace_root) = local_workspace_root.as_ref() {
|
||||||
if let Some(module) =
|
if let Some(module) =
|
||||||
crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, &workspace_root).await
|
crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await
|
||||||
{
|
{
|
||||||
feature_registry = feature_registry.with_module(module);
|
feature_registry = feature_registry.with_module(module);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let worker = worker.engine_mut();
|
let worker = worker.engine_mut();
|
||||||
|
|
@ -684,7 +699,13 @@ where
|
||||||
"[feature.memory].enabled = true requires a [memory] configuration section",
|
"[feature.memory].enabled = true requires a [memory] configuration section",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root);
|
let workspace_root = local_workspace_root.as_ref().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"memory tools require local Worker filesystem authority",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
|
||||||
let query_cfg = memory::tool::QueryConfig::from(mem);
|
let query_cfg = memory::tool::QueryConfig::from(mem);
|
||||||
worker.register_tool(memory::tool::read_tool_with_usage(
|
worker.register_tool(memory::tool::read_tool_with_usage(
|
||||||
layout.clone(),
|
layout.clone(),
|
||||||
|
|
@ -709,12 +730,27 @@ where
|
||||||
"[feature.workers].enabled = true requires non-empty [[delegation_scope.allow]]",
|
"[feature.workers].enabled = true requires non-empty [[delegation_scope.allow]]",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let spawner_cwd = local_filesystem
|
||||||
|
.as_ref()
|
||||||
|
.map(|local| local.cwd.clone())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"worker spawn tools require local Worker filesystem authority",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let spawner_workspace_root = local_workspace_root.clone().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"worker spawn tools require local Worker filesystem authority",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
worker.register_tool(spawn_worker_tool(
|
worker.register_tool(spawn_worker_tool(
|
||||||
spawner_name.clone(),
|
spawner_name.clone(),
|
||||||
spawner_socket,
|
spawner_socket,
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
workspace_root.clone(),
|
spawner_workspace_root,
|
||||||
cwd.clone(),
|
spawner_cwd.clone(),
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
self_parent_socket,
|
self_parent_socket,
|
||||||
spawner_manifest,
|
spawner_manifest,
|
||||||
|
|
@ -728,7 +764,7 @@ where
|
||||||
worker_metadata_store,
|
worker_metadata_store,
|
||||||
spawner_name,
|
spawner_name,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
cwd,
|
Some(spawner_cwd),
|
||||||
spawned_registry,
|
spawned_registry,
|
||||||
);
|
);
|
||||||
worker.register_tool(list_workers_tool(discovery.clone()));
|
worker.register_tool(list_workers_tool(discovery.clone()));
|
||||||
|
|
@ -737,7 +773,9 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _feature_install_report = worker.install_features(feature_registry);
|
let _feature_install_report = worker.install_features(feature_registry);
|
||||||
|
if let Some(tracker) = tracker {
|
||||||
worker.attach_tracker(tracker);
|
worker.attach_tracker(tracker);
|
||||||
|
}
|
||||||
Ok(fs_for_view)
|
Ok(fs_for_view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -774,11 +812,14 @@ async fn controller_loop<C, St>(
|
||||||
.parent()
|
.parent()
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| runtime_dir.path().to_path_buf());
|
.unwrap_or_else(|| runtime_dir.path().to_path_buf());
|
||||||
|
let discovery_cwd = worker
|
||||||
|
.local_working_directory()
|
||||||
|
.map(|local| local.cwd.clone());
|
||||||
let discovery = WorkerDiscovery::new(
|
let discovery = WorkerDiscovery::new(
|
||||||
worker.store().clone(),
|
worker.store().clone(),
|
||||||
spawner_name.clone(),
|
spawner_name.clone(),
|
||||||
discovery_runtime_base,
|
discovery_runtime_base,
|
||||||
worker.cwd().to_path_buf(),
|
discovery_cwd,
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
);
|
);
|
||||||
let mut pending: Option<PendingRun> = None;
|
let mut pending: Option<PendingRun> = None;
|
||||||
|
|
@ -1445,7 +1486,10 @@ where
|
||||||
.collect();
|
.collect();
|
||||||
protocol::Greeting {
|
protocol::Greeting {
|
||||||
worker_name: manifest.worker.name.clone(),
|
worker_name: manifest.worker.name.clone(),
|
||||||
cwd: worker.cwd().display().to_string(),
|
cwd: worker
|
||||||
|
.local_working_directory()
|
||||||
|
.map(|local| local.cwd.display().to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
provider: provider_name,
|
provider: provider_name,
|
||||||
model: model_id,
|
model: model_id,
|
||||||
scope_summary: worker.scope_snapshot().summary(),
|
scope_summary: worker.scope_snapshot().summary(),
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ pub struct WorkerDiscovery<St> {
|
||||||
store: St,
|
store: St,
|
||||||
self_worker_name: String,
|
self_worker_name: String,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
cwd: PathBuf,
|
cwd: Option<PathBuf>,
|
||||||
store_dir: Option<PathBuf>,
|
store_dir: Option<PathBuf>,
|
||||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ where
|
||||||
store: St,
|
store: St,
|
||||||
self_worker_name: String,
|
self_worker_name: String,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
cwd: PathBuf,
|
cwd: Option<PathBuf>,
|
||||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let store_dir = store.root_dir();
|
let store_dir = store.root_dir();
|
||||||
|
|
@ -432,13 +432,19 @@ where
|
||||||
) -> Result<(), WorkerDiscoveryError> {
|
) -> Result<(), WorkerDiscoveryError> {
|
||||||
let runtime_command =
|
let runtime_command =
|
||||||
WorkerRuntimeCommand::resolve().map_err(WorkerDiscoveryError::RestoreSpawn)?;
|
WorkerRuntimeCommand::resolve().map_err(WorkerDiscoveryError::RestoreSpawn)?;
|
||||||
|
let Some(cwd) = &self.cwd else {
|
||||||
|
return Err(WorkerDiscoveryError::NotRestorable {
|
||||||
|
worker_name: worker_name.to_string(),
|
||||||
|
reason: "restore requires local Worker filesystem authority".into(),
|
||||||
|
});
|
||||||
|
};
|
||||||
let mut command = Command::new(runtime_command.program());
|
let mut command = Command::new(runtime_command.program());
|
||||||
command
|
command
|
||||||
.args(runtime_command.prefix_args())
|
.args(runtime_command.prefix_args())
|
||||||
.arg("--worker")
|
.arg("--worker")
|
||||||
.arg(worker_name)
|
.arg(worker_name)
|
||||||
.arg("--require-worker-state")
|
.arg("--require-worker-state")
|
||||||
.current_dir(&self.cwd)
|
.current_dir(cwd)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
|
|
@ -1139,6 +1145,7 @@ mod tests {
|
||||||
worker_name: "parent".into(),
|
worker_name: "parent".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: vec![
|
spawned_children: vec![
|
||||||
child("child-live", &live_socket),
|
child("child-live", &live_socket),
|
||||||
child("child-stale", &stale_socket),
|
child("child-stale", &stale_socket),
|
||||||
|
|
@ -1159,6 +1166,7 @@ mod tests {
|
||||||
active_child_segment,
|
active_child_segment,
|
||||||
)),
|
)),
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -1173,6 +1181,7 @@ mod tests {
|
||||||
active_child_segment,
|
active_child_segment,
|
||||||
)),
|
)),
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -1184,6 +1193,7 @@ mod tests {
|
||||||
worker_name: "child-pending".into(),
|
worker_name: "child-pending".into(),
|
||||||
active: Some(WorkerActiveSegmentRef::pending_segment(pending_session_id)),
|
active: Some(WorkerActiveSegmentRef::pending_segment(pending_session_id)),
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -1198,6 +1208,7 @@ mod tests {
|
||||||
new_segment_id(),
|
new_segment_id(),
|
||||||
)),
|
)),
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -1209,6 +1220,7 @@ mod tests {
|
||||||
worker_name: "peer".into(),
|
worker_name: "peer".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: vec![session_store::WorkerPeer {
|
peers: vec![session_store::WorkerPeer {
|
||||||
|
|
@ -1228,7 +1240,7 @@ mod tests {
|
||||||
store.clone(),
|
store.clone(),
|
||||||
"parent".into(),
|
"parent".into(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
registry,
|
registry,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1355,7 +1367,7 @@ mod tests {
|
||||||
store.clone(),
|
store.clone(),
|
||||||
"source".into(),
|
"source".into(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
SpawnedWorkerRegistry::new(runtime_dir),
|
SpawnedWorkerRegistry::new(runtime_dir),
|
||||||
);
|
);
|
||||||
let result = discovery.register_peer("target").unwrap();
|
let result = discovery.register_peer("target").unwrap();
|
||||||
|
|
@ -1390,7 +1402,7 @@ mod tests {
|
||||||
store,
|
store,
|
||||||
"source".into(),
|
"source".into(),
|
||||||
runtime_base,
|
runtime_base,
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
SpawnedWorkerRegistry::new(runtime_dir),
|
SpawnedWorkerRegistry::new(runtime_dir),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1415,6 +1427,7 @@ mod tests {
|
||||||
worker_name: "source".into(),
|
worker_name: "source".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: vec![session_store::WorkerPeer {
|
peers: vec![session_store::WorkerPeer {
|
||||||
|
|
@ -1430,7 +1443,7 @@ mod tests {
|
||||||
store.clone(),
|
store.clone(),
|
||||||
"source".into(),
|
"source".into(),
|
||||||
runtime_base,
|
runtime_base,
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
SpawnedWorkerRegistry::new(runtime_dir),
|
SpawnedWorkerRegistry::new(runtime_dir),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1455,6 +1468,7 @@ mod tests {
|
||||||
worker_name: "source".into(),
|
worker_name: "source".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: vec![session_store::WorkerPeer {
|
peers: vec![session_store::WorkerPeer {
|
||||||
|
|
@ -1468,6 +1482,7 @@ mod tests {
|
||||||
worker_name: "target".into(),
|
worker_name: "target".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: vec![session_store::WorkerPeer {
|
peers: vec![session_store::WorkerPeer {
|
||||||
|
|
@ -1481,7 +1496,7 @@ mod tests {
|
||||||
store,
|
store,
|
||||||
"source".into(),
|
"source".into(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
SpawnedWorkerRegistry::new(runtime_dir),
|
SpawnedWorkerRegistry::new(runtime_dir),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1573,6 +1588,7 @@ mod tests {
|
||||||
worker_name: "source".into(),
|
worker_name: "source".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: vec![session_store::WorkerPeer {
|
peers: vec![session_store::WorkerPeer {
|
||||||
|
|
@ -1586,6 +1602,7 @@ mod tests {
|
||||||
worker_name: "target".into(),
|
worker_name: "target".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: vec![session_store::WorkerPeer {
|
peers: vec![session_store::WorkerPeer {
|
||||||
|
|
@ -1599,7 +1616,7 @@ mod tests {
|
||||||
store,
|
store,
|
||||||
"source".into(),
|
"source".into(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
SpawnedWorkerRegistry::new(runtime_dir),
|
SpawnedWorkerRegistry::new(runtime_dir),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1689,6 +1706,7 @@ mod tests {
|
||||||
worker_name: "source".into(),
|
worker_name: "source".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: vec![child("target", &socket)],
|
spawned_children: vec![child("target", &socket)],
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -1701,7 +1719,7 @@ mod tests {
|
||||||
store,
|
store,
|
||||||
"source".into(),
|
"source".into(),
|
||||||
runtime_base,
|
runtime_base,
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
SpawnedWorkerRegistry::new(runtime_dir),
|
SpawnedWorkerRegistry::new(runtime_dir),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ use std::ffi::OsString;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use crate::{PromptLoader, Worker, WorkerController};
|
use crate::{
|
||||||
|
PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext,
|
||||||
|
WorkspaceId,
|
||||||
|
};
|
||||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||||
use manifest::{
|
use manifest::{
|
||||||
Permission, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule,
|
Permission, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule,
|
||||||
|
|
@ -103,6 +106,24 @@ fn runtime_workspace_root(cli: &Cli) -> Result<PathBuf, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_workspace_context(workspace_root: &Path) -> WorkerWorkspaceContext {
|
||||||
|
WorkerWorkspaceContext::local_filesystem(read_workspace_id_hint(workspace_root))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_workspace_id_hint(workspace_root: &Path) -> Option<WorkspaceId> {
|
||||||
|
let path = workspace_root.join(".yoi/workspace.toml");
|
||||||
|
let contents = std::fs::read_to_string(path).ok()?;
|
||||||
|
let value = toml::from_str::<toml::Value>(&contents).ok()?;
|
||||||
|
let id = value.get("id")?.as_str()?.to_string();
|
||||||
|
match WorkspaceId::new(id) {
|
||||||
|
Ok(id) => Some(id),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("ignoring invalid workspace id in .yoi/workspace.toml: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime_worker_name(cli: &Cli, workspace_root: &Path) -> String {
|
fn runtime_worker_name(cli: &Cli, workspace_root: &Path) -> String {
|
||||||
cli.worker
|
cli.worker
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -511,6 +532,9 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||||
|
let filesystem_authority =
|
||||||
|
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone());
|
||||||
|
let workspace_context = runtime_workspace_context(&workspace_root);
|
||||||
|
|
||||||
let mut worker = if cli.adopt {
|
let mut worker = if cli.adopt {
|
||||||
let callback = match cli.callback.clone() {
|
let callback = match cli.callback.clone() {
|
||||||
|
|
@ -525,8 +549,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||||
store,
|
store,
|
||||||
loader,
|
loader,
|
||||||
callback,
|
callback,
|
||||||
workspace_root.clone(),
|
workspace_context.clone(),
|
||||||
cwd.clone(),
|
filesystem_authority.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -556,8 +580,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
loader,
|
loader,
|
||||||
workspace_root.clone(),
|
workspace_context.clone(),
|
||||||
cwd.clone(),
|
filesystem_authority.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -576,8 +600,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
loader,
|
loader,
|
||||||
workspace_root.clone(),
|
workspace_context.clone(),
|
||||||
cwd.clone(),
|
filesystem_authority.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -597,8 +621,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
loader,
|
loader,
|
||||||
workspace_root.clone(),
|
workspace_context.clone(),
|
||||||
cwd.clone(),
|
filesystem_authority.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -619,8 +643,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
loader,
|
loader,
|
||||||
workspace_root.clone(),
|
workspace_context.clone(),
|
||||||
cwd.clone(),
|
filesystem_authority.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -845,15 +869,15 @@ recursive = true
|
||||||
external.display()
|
external.display()
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let profile = tmp.path().join("profile.lua");
|
let profile = tmp.path().join("profile.toml");
|
||||||
write(
|
write(
|
||||||
&profile,
|
&profile,
|
||||||
r#"
|
r#"
|
||||||
local yoi = require("yoi")
|
slug = "override-scope"
|
||||||
return yoi.profile {
|
|
||||||
slug = "override-scope",
|
[model]
|
||||||
model = { scheme = "anthropic", model_id = "test-model" },
|
scheme = "anthropic"
|
||||||
}
|
model_id = "test-model"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let cli = Cli::try_parse_from([
|
let cli = Cli::try_parse_from([
|
||||||
|
|
@ -889,7 +913,7 @@ return yoi.profile {
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_uses_selected_profile() {
|
fn profile_uses_selected_profile() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let profile = tmp.path().join("profile.lua");
|
let profile = tmp.path().join("profile.toml");
|
||||||
let cli = Cli::try_parse_from([
|
let cli = Cli::try_parse_from([
|
||||||
"yoi worker",
|
"yoi worker",
|
||||||
"--profile",
|
"--profile",
|
||||||
|
|
@ -1223,8 +1247,14 @@ permission = "write"
|
||||||
fn profile_conflicts_with_manifest_and_restore_modes() {
|
fn profile_conflicts_with_manifest_and_restore_modes() {
|
||||||
let segment_id = session_store::new_segment_id().to_string();
|
let segment_id = session_store::new_segment_id().to_string();
|
||||||
for args in [
|
for args in [
|
||||||
vec!["yoi worker", "--profile", "p.lua", "--manifest", "m.toml"],
|
vec!["yoi worker", "--profile", "p.toml", "--manifest", "m.toml"],
|
||||||
vec!["yoi worker", "--profile", "p.lua", "--session", &segment_id],
|
vec![
|
||||||
|
"yoi worker",
|
||||||
|
"--profile",
|
||||||
|
"p.toml",
|
||||||
|
"--session",
|
||||||
|
&segment_id,
|
||||||
|
],
|
||||||
] {
|
] {
|
||||||
let err = Cli::try_parse_from(args).unwrap_err();
|
let err = Cli::try_parse_from(args).unwrap_err();
|
||||||
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||||
|
|
@ -1233,9 +1263,9 @@ permission = "write"
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_and_worker_are_independent_startup_inputs() {
|
fn profile_and_worker_are_independent_startup_inputs() {
|
||||||
let cli =
|
let cli = Cli::try_parse_from(["yoi worker", "--profile", "p.toml", "--worker", "agent"])
|
||||||
Cli::try_parse_from(["yoi worker", "--profile", "p.lua", "--worker", "agent"]).unwrap();
|
.unwrap();
|
||||||
assert_eq!(cli.profile.as_deref(), Some("p.lua"));
|
assert_eq!(cli.profile.as_deref(), Some("p.toml"));
|
||||||
assert_eq!(cli.worker.as_deref(), Some("agent"));
|
assert_eq!(cli.worker.as_deref(), Some("agent"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ impl ProtocolProviderContribution {
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum FeatureRuntimeKind {
|
pub enum FeatureRuntimeKind {
|
||||||
Builtin,
|
Builtin,
|
||||||
LuaProfile,
|
Profile,
|
||||||
ExternalPlugin,
|
ExternalPlugin,
|
||||||
ProtocolProvider,
|
ProtocolProvider,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,7 @@ pub use provider::{ProviderError, build_client};
|
||||||
pub use runtime::dir::RuntimeDir;
|
pub use runtime::dir::RuntimeDir;
|
||||||
pub use segment_log_sink::SegmentLogSink;
|
pub use segment_log_sink::SegmentLogSink;
|
||||||
pub use shared_state::WorkerSharedState;
|
pub use shared_state::WorkerSharedState;
|
||||||
pub use worker::{Worker, WorkerError, WorkerRunResult, apply_worker_manifest};
|
pub use worker::{
|
||||||
|
LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult,
|
||||||
|
WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, WorkspaceIdError, apply_worker_manifest,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
//! `set_system_prompt`. Subsequent turns and compactions reuse that
|
//! `set_system_prompt`. Subsequent turns and compactions reuse that
|
||||||
//! materialised string verbatim.
|
//! materialised string verbatim.
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
#[cfg(test)]
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -146,7 +148,7 @@ impl std::fmt::Debug for SystemPromptTemplate {
|
||||||
/// templates cannot drop them on the floor.
|
/// templates cannot drop them on the floor.
|
||||||
pub struct SystemPromptContext<'a> {
|
pub struct SystemPromptContext<'a> {
|
||||||
pub now: DateTime<Utc>,
|
pub now: DateTime<Utc>,
|
||||||
pub cwd: &'a Path,
|
pub cwd: Cow<'a, str>,
|
||||||
/// Language policy exposed to instruction templates as `{{ language }}`.
|
/// Language policy exposed to instruction templates as `{{ language }}`.
|
||||||
pub language: &'a str,
|
pub language: &'a str,
|
||||||
pub scope: &'a Scope,
|
pub scope: &'a Scope,
|
||||||
|
|
@ -189,7 +191,7 @@ impl<'a> SystemPromptContext<'a> {
|
||||||
"datetime".into(),
|
"datetime".into(),
|
||||||
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
||||||
);
|
);
|
||||||
root.insert("cwd".into(), Value::from(self.cwd.display().to_string()));
|
root.insert("cwd".into(), Value::from(self.cwd.as_ref()));
|
||||||
root.insert("language".into(), Value::from(self.language));
|
root.insert("language".into(), Value::from(self.language));
|
||||||
root.insert(
|
root.insert(
|
||||||
"tools".into(),
|
"tools".into(),
|
||||||
|
|
@ -442,7 +444,7 @@ mod tests {
|
||||||
) -> SystemPromptContext<'a> {
|
) -> SystemPromptContext<'a> {
|
||||||
SystemPromptContext {
|
SystemPromptContext {
|
||||||
now: fixed_now(),
|
now: fixed_now(),
|
||||||
cwd,
|
cwd: cwd.display().to_string().into(),
|
||||||
language: manifest::defaults::WORKER_LANGUAGE,
|
language: manifest::defaults::WORKER_LANGUAGE,
|
||||||
scope,
|
scope,
|
||||||
tool_names: tools,
|
tool_names: tools,
|
||||||
|
|
@ -461,7 +463,7 @@ mod tests {
|
||||||
) -> SystemPromptContext<'a> {
|
) -> SystemPromptContext<'a> {
|
||||||
SystemPromptContext {
|
SystemPromptContext {
|
||||||
now: fixed_now(),
|
now: fixed_now(),
|
||||||
cwd,
|
cwd: cwd.display().to_string().into(),
|
||||||
language: manifest::defaults::WORKER_LANGUAGE,
|
language: manifest::defaults::WORKER_LANGUAGE,
|
||||||
scope,
|
scope,
|
||||||
tool_names: Vec::new(),
|
tool_names: Vec::new(),
|
||||||
|
|
@ -480,7 +482,7 @@ mod tests {
|
||||||
) -> SystemPromptContext<'a> {
|
) -> SystemPromptContext<'a> {
|
||||||
SystemPromptContext {
|
SystemPromptContext {
|
||||||
now: fixed_now(),
|
now: fixed_now(),
|
||||||
cwd,
|
cwd: cwd.display().to_string().into(),
|
||||||
language: manifest::defaults::WORKER_LANGUAGE,
|
language: manifest::defaults::WORKER_LANGUAGE,
|
||||||
scope,
|
scope,
|
||||||
tool_names: Vec::new(),
|
tool_names: Vec::new(),
|
||||||
|
|
@ -499,7 +501,7 @@ mod tests {
|
||||||
) -> SystemPromptContext<'a> {
|
) -> SystemPromptContext<'a> {
|
||||||
SystemPromptContext {
|
SystemPromptContext {
|
||||||
now: fixed_now(),
|
now: fixed_now(),
|
||||||
cwd,
|
cwd: cwd.display().to_string().into(),
|
||||||
language: manifest::defaults::WORKER_LANGUAGE,
|
language: manifest::defaults::WORKER_LANGUAGE,
|
||||||
scope,
|
scope,
|
||||||
tool_names: Vec::new(),
|
tool_names: Vec::new(),
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,9 @@ fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelecto
|
||||||
|| raw.starts_with("./")
|
|| raw.starts_with("./")
|
||||||
|| raw.starts_with("../")
|
|| raw.starts_with("../")
|
||||||
|| raw.contains('/')
|
|| raw.contains('/')
|
||||||
|| raw.ends_with(".lua")
|
|| raw.ends_with(".dcdl")
|
||||||
|
|| raw.ends_with(".json")
|
||||||
|
|| raw.ends_with(".toml")
|
||||||
|| raw.ends_with(".nix")
|
|| raw.ends_with(".nix")
|
||||||
{
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
@ -1181,25 +1183,31 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CODER_PROFILE: &str = r#"
|
const CODER_PROFILE: &str = r#"
|
||||||
local profile = require("yoi.profile")
|
slug = "coder"
|
||||||
local scope = require("yoi.scope")
|
scope = "workspace_write"
|
||||||
return profile {
|
|
||||||
slug = "coder",
|
[model]
|
||||||
model = { scheme = "anthropic", model_id = "coder-model" },
|
scheme = "anthropic"
|
||||||
engine = { instruction = "$yoi/coder", language = "Coderish", max_tokens = 2222 },
|
model_id = "coder-model"
|
||||||
scope = scope.workspace_write(),
|
|
||||||
}
|
[engine]
|
||||||
|
instruction = "$yoi/coder"
|
||||||
|
language = "Coderish"
|
||||||
|
max_tokens = 2222
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const REVIEWER_PROFILE: &str = r#"
|
const REVIEWER_PROFILE: &str = r#"
|
||||||
local profile = require("yoi.profile")
|
slug = "reviewer"
|
||||||
local scope = require("yoi.scope")
|
scope = "workspace_write"
|
||||||
return profile {
|
|
||||||
slug = "reviewer",
|
[model]
|
||||||
model = { scheme = "anthropic", model_id = "reviewer-model" },
|
scheme = "anthropic"
|
||||||
engine = { instruction = "$yoi/reviewer", language = "Reviewerish", max_tokens = 3333 },
|
model_id = "reviewer-model"
|
||||||
scope = scope.workspace_write(),
|
|
||||||
}
|
[engine]
|
||||||
|
instruction = "$yoi/reviewer"
|
||||||
|
language = "Reviewerish"
|
||||||
|
max_tokens = 3333
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1297,8 +1305,8 @@ return profile {
|
||||||
&project,
|
&project,
|
||||||
Some("reviewer"),
|
Some("reviewer"),
|
||||||
&[
|
&[
|
||||||
("coder", "coder.lua", CODER_PROFILE),
|
("coder", "coder.toml", CODER_PROFILE),
|
||||||
("reviewer", "reviewer.lua", REVIEWER_PROFILE),
|
("reviewer", "reviewer.toml", REVIEWER_PROFILE),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let parent = parent_manifest(&project, None);
|
let parent = parent_manifest(&project, None);
|
||||||
|
|
@ -1333,8 +1341,8 @@ return profile {
|
||||||
&project,
|
&project,
|
||||||
Some("coder"),
|
Some("coder"),
|
||||||
&[
|
&[
|
||||||
("coder", "coder.lua", CODER_PROFILE),
|
("coder", "coder.toml", CODER_PROFILE),
|
||||||
("reviewer", "reviewer.lua", REVIEWER_PROFILE),
|
("reviewer", "reviewer.toml", REVIEWER_PROFILE),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let parent = parent_manifest(&project, None);
|
let parent = parent_manifest(&project, None);
|
||||||
|
|
@ -1414,7 +1422,7 @@ return profile {
|
||||||
let available = write_project_profile_registry(
|
let available = write_project_profile_registry(
|
||||||
&project,
|
&project,
|
||||||
Some("reviewer"),
|
Some("reviewer"),
|
||||||
&[("reviewer", "reviewer.lua", REVIEWER_PROFILE)],
|
&[("reviewer", "reviewer.toml", REVIEWER_PROFILE)],
|
||||||
);
|
);
|
||||||
let parent = parent_manifest(&project, None);
|
let parent = parent_manifest(&project, None);
|
||||||
let scope = vec![abs_rule(&delegated, Permission::Write)];
|
let scope = vec![abs_rule(&delegated, Permission::Write)];
|
||||||
|
|
@ -1451,7 +1459,7 @@ return profile {
|
||||||
let available = write_project_profile_registry(
|
let available = write_project_profile_registry(
|
||||||
&project,
|
&project,
|
||||||
Some("reviewer"),
|
Some("reviewer"),
|
||||||
&[("reviewer", "reviewer.lua", REVIEWER_PROFILE)],
|
&[("reviewer", "reviewer.toml", REVIEWER_PROFILE)],
|
||||||
);
|
);
|
||||||
let parent = parent_manifest(&parent_root, Some(&parent_root.join("deny")));
|
let parent = parent_manifest(&parent_root, Some(&parent_root.join("deny")));
|
||||||
let scope = vec![abs_rule(&delegated, Permission::Read)];
|
let scope = vec![abs_rule(&delegated, Permission::Read)];
|
||||||
|
|
@ -1497,12 +1505,12 @@ return profile {
|
||||||
let available = write_project_profile_registry(
|
let available = write_project_profile_registry(
|
||||||
&project,
|
&project,
|
||||||
None,
|
None,
|
||||||
&[("coder", "coder.lua", CODER_PROFILE)],
|
&[("coder", "coder.toml", CODER_PROFILE)],
|
||||||
);
|
);
|
||||||
let parent = parent_manifest(&project, None);
|
let parent = parent_manifest(&project, None);
|
||||||
let scope = vec![abs_rule(&project, Permission::Read)];
|
let scope = vec![abs_rule(&project, Permission::Read)];
|
||||||
|
|
||||||
let invalid = parse_spawn_profile_selector(Some("./reviewer.lua"))
|
let invalid = parse_spawn_profile_selector(Some("./reviewer.toml"))
|
||||||
.map_err(|msg| format!("{msg}{}", available.error_suffix()))
|
.map_err(|msg| format!("{msg}{}", available.error_suffix()))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(invalid.contains("Use `default`, `inherit`"));
|
assert!(invalid.contains("Use `default`, `inherit`"));
|
||||||
|
|
@ -1521,7 +1529,7 @@ return profile {
|
||||||
assert!(default_config.contains("\"name\":\"child\""));
|
assert!(default_config.contains("\"name\":\"child\""));
|
||||||
|
|
||||||
let user_config = tmp.path().join("user-profiles.toml");
|
let user_config = tmp.path().join("user-profiles.toml");
|
||||||
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.lua\"\n").unwrap();
|
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
|
||||||
let project_config = project.join(".yoi/profiles.toml");
|
let project_config = project.join(".yoi/profiles.toml");
|
||||||
let ambiguous = AvailableProfiles {
|
let ambiguous = AvailableProfiles {
|
||||||
registry: Some(
|
registry: Some(
|
||||||
|
|
@ -1550,9 +1558,9 @@ return profile {
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_profile_selector_rejects_path_like_values() {
|
fn spawn_profile_selector_rejects_path_like_values() {
|
||||||
for raw in [
|
for raw in [
|
||||||
"./reviewer.lua",
|
"./reviewer.toml",
|
||||||
"path:./reviewer.lua",
|
"path:./reviewer.toml",
|
||||||
"/tmp/reviewer.lua",
|
"/tmp/reviewer.toml",
|
||||||
"legacy.nix",
|
"legacy.nix",
|
||||||
] {
|
] {
|
||||||
let err = parse_spawn_profile_selector(Some(raw)).unwrap_err();
|
let err = parse_spawn_profile_selector(Some(raw)).unwrap_err();
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,7 @@ mod tests {
|
||||||
worker_name: "orchestrator".into(),
|
worker_name: "orchestrator".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -395,6 +396,7 @@ mod tests {
|
||||||
worker_name: "companion".into(),
|
worker_name: "companion".into(),
|
||||||
active: None,
|
active: None,
|
||||||
workspace_root: None,
|
workspace_root: None,
|
||||||
|
workspace_id: None,
|
||||||
spawned_children: Vec::new(),
|
spawned_children: Vec::new(),
|
||||||
reclaimed_children: Vec::new(),
|
reclaimed_children: Vec::new(),
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
|
|
@ -414,7 +416,7 @@ mod tests {
|
||||||
store,
|
store,
|
||||||
"orchestrator".into(),
|
"orchestrator".into(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
root.path().to_path_buf(),
|
Some(root.path().to_path_buf()),
|
||||||
SpawnedWorkerRegistry::new(runtime_dir),
|
SpawnedWorkerRegistry::new(runtime_dir),
|
||||||
),
|
),
|
||||||
"companion",
|
"companion",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -164,7 +164,14 @@ async fn make_worker_with_manifest(
|
||||||
std::mem::forget(pwd_tmp);
|
std::mem::forget(pwd_tmp);
|
||||||
|
|
||||||
let worker = Engine::new(client);
|
let worker = Engine::new(client);
|
||||||
let mut worker = Worker::new(manifest, worker, store, pwd, scope)
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
worker,
|
||||||
|
store,
|
||||||
|
worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
worker.enable_worker_metadata_write_through().unwrap();
|
worker.enable_worker_metadata_write_through().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,14 @@ async fn make_worker_with(
|
||||||
|
|
||||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||||
let worker = Engine::new(client);
|
let worker = Engine::new(client);
|
||||||
Worker::new(manifest, worker, store, pwd, scope)
|
Worker::new(
|
||||||
|
manifest,
|
||||||
|
worker,
|
||||||
|
store,
|
||||||
|
worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use session_store::{CombinedStore, FsWorkerStore};
|
use session_store::{CombinedStore, FsWorkerStore};
|
||||||
use session_store::{FsStore, LogEntry};
|
use session_store::{FsStore, LogEntry};
|
||||||
|
|
||||||
use worker::{Event, Method, Worker, WorkerController, WorkerHandle, WorkerManifest, WorkerStatus};
|
use worker::{
|
||||||
|
Event, Method, Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle,
|
||||||
|
WorkerManifest, WorkerStatus, WorkerWorkspaceContext,
|
||||||
|
};
|
||||||
|
|
||||||
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||||
|
|
||||||
|
|
@ -186,7 +189,15 @@ async fn make_worker_with_pwd_and_manifest(
|
||||||
std::mem::forget(pwd_tmp);
|
std::mem::forget(pwd_tmp);
|
||||||
|
|
||||||
let worker = Engine::new(client);
|
let worker = Engine::new(client);
|
||||||
let worker = Worker::new(manifest, worker, store, pwd.clone(), scope)
|
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
|
||||||
|
let worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
worker,
|
||||||
|
store,
|
||||||
|
WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
authority,
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(worker, pwd)
|
(worker, pwd)
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,14 @@ async fn make_worker(
|
||||||
let mut worker = Engine::new(client);
|
let mut worker = Engine::new(client);
|
||||||
worker.register_tool(big_content_tool_definition(tool_name));
|
worker.register_tool(big_content_tool_definition(tool_name));
|
||||||
|
|
||||||
let worker = Worker::new(manifest, worker, store, pwd, scope)
|
let worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
worker,
|
||||||
|
store,
|
||||||
|
worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(worker, store_tmp, pwd_tmp)
|
(worker, store_tmp, pwd_tmp)
|
||||||
|
|
@ -453,7 +460,14 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
|
||||||
// the failure path: at least one metric attempts to write.
|
// the failure path: at least one metric attempts to write.
|
||||||
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
|
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
|
||||||
let worker = Engine::new(client);
|
let worker = Engine::new(client);
|
||||||
let mut worker = Worker::new(manifest, worker, store.clone(), pwd, scope)
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
worker,
|
||||||
|
store.clone(),
|
||||||
|
worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -522,7 +536,14 @@ permission = "write"
|
||||||
let pwd = pwd_tmp.path().to_path_buf();
|
let pwd = pwd_tmp.path().to_path_buf();
|
||||||
let scope = worker::Scope::writable(&pwd).unwrap();
|
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||||
let worker = Engine::new(client);
|
let worker = Engine::new(client);
|
||||||
let mut worker = Worker::new(manifest, worker, store.clone(), pwd, scope)
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
worker,
|
||||||
|
store.clone(),
|
||||||
|
worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let session_id = worker.session_id();
|
let session_id = worker.session_id();
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,15 @@ async fn make_worker_with_body(
|
||||||
std::mem::forget(user_prompts_tmp);
|
std::mem::forget(user_prompts_tmp);
|
||||||
|
|
||||||
let worker = Engine::new(client);
|
let worker = Engine::new(client);
|
||||||
let mut worker = Worker::new(manifest, worker, store, pwd.clone(), scope).await?;
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
worker,
|
||||||
|
store,
|
||||||
|
worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let template = SystemPromptTemplate::parse("$user/test", loader)
|
let template = SystemPromptTemplate::parse("$user/test", loader)
|
||||||
.map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?;
|
.map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?;
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,13 @@
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use worker_runtime::catalog::ProfileSelector;
|
|
||||||
use worker_runtime::config_bundle::{
|
|
||||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
|
||||||
};
|
|
||||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
|
||||||
|
|
||||||
use crate::hosts::{
|
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic, WorkerSummary};
|
||||||
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
|
|
||||||
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
|
|
||||||
WorkerSummary, WorkerTranscriptItem as RuntimeTranscriptItem, WorkerTranscriptProjection,
|
|
||||||
};
|
|
||||||
|
|
||||||
const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime";
|
|
||||||
const COMPANION_PROFILE_ID: &str = "builtin:companion";
|
|
||||||
const COMPANION_CONFIG_BUNDLE_ID: &str = "workspace-companion-config";
|
|
||||||
const MAX_MESSAGE_CHARS: usize = 8_000;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CompanionState {
|
pub enum CompanionState {
|
||||||
Ready,
|
Disabled,
|
||||||
Busy,
|
|
||||||
Error,
|
|
||||||
Timeout,
|
|
||||||
Cancelled,
|
|
||||||
Accepted,
|
|
||||||
Rejected,
|
Rejected,
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
@ -94,698 +72,77 @@ pub struct CompanionTranscriptItem {
|
||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
pub struct CompanionConsole;
|
||||||
struct CompanionWorkerState {
|
|
||||||
state: CompanionState,
|
|
||||||
worker: Option<WorkerSummary>,
|
|
||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CompanionConsole {
|
|
||||||
runtime: Arc<RuntimeRegistry>,
|
|
||||||
worker: Mutex<CompanionWorkerState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CompanionConsole {
|
impl CompanionConsole {
|
||||||
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self {
|
pub fn disabled() -> Self {
|
||||||
let initial = spawn_companion_worker(&runtime);
|
Self
|
||||||
Self {
|
|
||||||
runtime,
|
|
||||||
worker: Mutex::new(initial),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> CompanionStatusResponse {
|
pub fn status(&self) -> CompanionStatusResponse {
|
||||||
match self.refresh_worker_state() {
|
CompanionStatusResponse {
|
||||||
Ok(worker) => CompanionStatusResponse {
|
state: CompanionState::Disabled,
|
||||||
state: worker.state,
|
|
||||||
worker: worker.worker.clone(),
|
|
||||||
transport: companion_transport(worker.worker.as_ref()),
|
|
||||||
diagnostics: worker.diagnostics.clone(),
|
|
||||||
},
|
|
||||||
Err(diagnostic) => CompanionStatusResponse {
|
|
||||||
state: CompanionState::Error,
|
|
||||||
worker: None,
|
worker: None,
|
||||||
transport: companion_transport(None),
|
transport: disabled_transport(),
|
||||||
diagnostics: vec![diagnostic],
|
diagnostics: vec![disabled_diagnostic()],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
|
||||||
match self.current_worker() {
|
CompanionTranscriptProjection {
|
||||||
Ok(Some(worker)) => {
|
state: CompanionState::Disabled,
|
||||||
match self
|
|
||||||
.runtime
|
|
||||||
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, start, limit)
|
|
||||||
{
|
|
||||||
Ok(transcript) => project_runtime_transcript(
|
|
||||||
&transcript,
|
|
||||||
companion_state_for_worker(&worker),
|
|
||||||
Vec::new(),
|
|
||||||
),
|
|
||||||
Err(error) => CompanionTranscriptProjection {
|
|
||||||
state: CompanionState::Error,
|
|
||||||
start,
|
start,
|
||||||
limit,
|
limit,
|
||||||
total_items: 0,
|
total_items: 0,
|
||||||
next_start: None,
|
next_start: None,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
diagnostics: vec![diagnostic(
|
diagnostics: vec![disabled_diagnostic()],
|
||||||
"companion_transcript_unavailable",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!("Companion Worker transcript is unavailable: {error:?}"),
|
|
||||||
)],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => CompanionTranscriptProjection {
|
|
||||||
state: CompanionState::Error,
|
|
||||||
start,
|
|
||||||
limit,
|
|
||||||
total_items: 0,
|
|
||||||
next_start: None,
|
|
||||||
items: Vec::new(),
|
|
||||||
diagnostics: vec![diagnostic(
|
|
||||||
"companion_worker_unavailable",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
"Workspace Companion Worker is unavailable",
|
|
||||||
)],
|
|
||||||
},
|
|
||||||
Err(diagnostic) => CompanionTranscriptProjection {
|
|
||||||
state: CompanionState::Error,
|
|
||||||
start,
|
|
||||||
limit,
|
|
||||||
total_items: 0,
|
|
||||||
next_start: None,
|
|
||||||
items: Vec::new(),
|
|
||||||
diagnostics: vec![diagnostic],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse {
|
pub fn send_message(&self, _request: CompanionMessageRequest) -> CompanionMessageResponse {
|
||||||
let content = request.content.trim().to_string();
|
disabled_message_response(CompanionState::Rejected)
|
||||||
if content.is_empty() {
|
|
||||||
return self.rejected_message_response(diagnostic(
|
|
||||||
"companion_message_empty",
|
|
||||||
DiagnosticSeverity::Warning,
|
|
||||||
"Companion message content is empty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if content.chars().count() > MAX_MESSAGE_CHARS {
|
|
||||||
return self.rejected_message_response(diagnostic(
|
|
||||||
"companion_message_too_large",
|
|
||||||
DiagnosticSeverity::Warning,
|
|
||||||
format!("Companion message exceeds the {MAX_MESSAGE_CHARS} character limit"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let worker = match self.current_worker() {
|
|
||||||
Ok(Some(worker)) => worker,
|
|
||||||
Ok(None) => {
|
|
||||||
return self.rejected_message_response(diagnostic(
|
|
||||||
"companion_worker_unavailable",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
"Workspace Companion Worker is unavailable",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(diagnostic) => return self.rejected_message_response(diagnostic),
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = self.runtime.send_input(
|
|
||||||
COMPANION_RUNTIME_ID,
|
|
||||||
&worker.worker_id,
|
|
||||||
WorkerInputRequest {
|
|
||||||
kind: WorkerInputKind::User,
|
|
||||||
content: content.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(result) => {
|
|
||||||
let state = match result.state {
|
|
||||||
WorkerOperationState::Accepted => CompanionState::Accepted,
|
|
||||||
WorkerOperationState::Unsupported | WorkerOperationState::Rejected => {
|
|
||||||
CompanionState::Rejected
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let diagnostics = if result.diagnostics.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
result.diagnostics.clone()
|
|
||||||
};
|
|
||||||
let projection = self.transcript(0, 200);
|
|
||||||
CompanionMessageResponse {
|
|
||||||
state,
|
|
||||||
worker: projection_worker(&self.status()),
|
|
||||||
user_item: projection
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|item| item.role == "user" && item.content == content)
|
|
||||||
.cloned(),
|
|
||||||
assistant_item: projection
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|item| item.role == "assistant")
|
|
||||||
.cloned(),
|
|
||||||
transcript: projection,
|
|
||||||
diagnostics,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => self.rejected_message_response(diagnostic(
|
|
||||||
"companion_worker_input_failed",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!("Companion Worker input dispatch failed: {error:?}"),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
|
||||||
let diagnostics = vec![diagnostic(
|
disabled_message_response(CompanionState::Cancelled)
|
||||||
"companion_cancel_no_active_run",
|
}
|
||||||
DiagnosticSeverity::Info,
|
}
|
||||||
"Workspace Companion has no active generation to cancel",
|
|
||||||
)];
|
fn disabled_message_response(state: CompanionState) -> CompanionMessageResponse {
|
||||||
let status = self.status();
|
|
||||||
let projection = self.transcript(0, 200);
|
|
||||||
CompanionMessageResponse {
|
CompanionMessageResponse {
|
||||||
state: CompanionState::Cancelled,
|
|
||||||
worker: status.worker,
|
|
||||||
user_item: None,
|
|
||||||
assistant_item: projection
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|item| item.role == "assistant")
|
|
||||||
.cloned(),
|
|
||||||
transcript: projection,
|
|
||||||
diagnostics,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
|
|
||||||
let status = self.status();
|
|
||||||
let projection = self.transcript(0, 200);
|
|
||||||
CompanionMessageResponse {
|
|
||||||
state: CompanionState::Rejected,
|
|
||||||
worker: status.worker,
|
|
||||||
user_item: None,
|
|
||||||
assistant_item: projection
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|item| item.role == "assistant")
|
|
||||||
.cloned(),
|
|
||||||
transcript: projection,
|
|
||||||
diagnostics: vec![diagnostic],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_worker(&self) -> Result<Option<WorkerSummary>, RuntimeDiagnostic> {
|
|
||||||
self.refresh_worker_state()
|
|
||||||
.map(|state| state.worker.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh_worker_state(&self) -> Result<CompanionWorkerState, RuntimeDiagnostic> {
|
|
||||||
let mut state = self.worker.lock().map_err(|_| {
|
|
||||||
diagnostic(
|
|
||||||
"companion_state_unavailable",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
"Companion state is unavailable",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let Some(worker_id) = state.worker.as_ref().map(|worker| worker.worker_id.clone()) else {
|
|
||||||
return Ok(CompanionWorkerState {
|
|
||||||
state: state.state,
|
|
||||||
worker: None,
|
|
||||||
diagnostics: state.diagnostics.clone(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.runtime.worker(COMPANION_RUNTIME_ID, &worker_id) {
|
|
||||||
Ok(worker) => {
|
|
||||||
let mut diagnostics = if worker.capabilities.can_accept_input {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
state.diagnostics.clone()
|
|
||||||
};
|
|
||||||
if !worker.capabilities.can_accept_input
|
|
||||||
&& !diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| diagnostic.code == "companion_worker_not_input_capable")
|
|
||||||
{
|
|
||||||
diagnostics.push(companion_not_input_capable_diagnostic(&worker));
|
|
||||||
}
|
|
||||||
state.state = companion_state_for_worker(&worker);
|
|
||||||
state.worker = Some(worker);
|
|
||||||
state.diagnostics = diagnostics;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
state.state = CompanionState::Error;
|
|
||||||
state.diagnostics = vec![diagnostic(
|
|
||||||
"companion_worker_lookup_failed",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!("Companion Worker lookup failed: {error:?}"),
|
|
||||||
)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CompanionWorkerState {
|
|
||||||
state: state.state,
|
|
||||||
worker: state.worker.clone(),
|
|
||||||
diagnostics: state.diagnostics.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn projection_worker(status: &CompanionStatusResponse) -> Option<WorkerSummary> {
|
|
||||||
status.worker.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
|
|
||||||
let selector = companion_profile_selector();
|
|
||||||
let mut diagnostics = Vec::new();
|
|
||||||
let config_bundle = companion_config_bundle();
|
|
||||||
|
|
||||||
match runtime.sync_config_bundle(COMPANION_RUNTIME_ID, config_bundle) {
|
|
||||||
Ok(result) => diagnostics.extend(result.diagnostics),
|
|
||||||
Err(error) => diagnostics.push(diagnostic(
|
|
||||||
"companion_config_bundle_sync_failed",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!("Workspace Companion config bundle sync failed: {error:?}"),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = runtime.spawn_worker(
|
|
||||||
COMPANION_RUNTIME_ID,
|
|
||||||
WorkerSpawnRequest {
|
|
||||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
|
||||||
requested_worker_name: Some("workspace-companion".to_string()),
|
|
||||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
|
||||||
expected_segments: 0,
|
|
||||||
},
|
|
||||||
profile: Some(selector),
|
|
||||||
initial_input: None,
|
|
||||||
working_directory_request: None,
|
|
||||||
resolved_working_directory_request: None,
|
|
||||||
resolved_working_directory: None,
|
|
||||||
resolved_config_bundle: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(response) => {
|
|
||||||
diagnostics.extend(response.diagnostics);
|
|
||||||
if let Some(worker) = response.worker {
|
|
||||||
if !worker.capabilities.can_accept_input {
|
|
||||||
diagnostics.push(companion_not_input_capable_diagnostic(&worker));
|
|
||||||
}
|
|
||||||
CompanionWorkerState {
|
|
||||||
state: companion_state_for_worker(&worker),
|
|
||||||
worker: Some(worker),
|
|
||||||
diagnostics,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
diagnostics.push(diagnostic(
|
|
||||||
"companion_worker_missing",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
"Workspace Companion Worker spawn did not return a Worker projection",
|
|
||||||
));
|
|
||||||
CompanionWorkerState {
|
|
||||||
state: CompanionState::Error,
|
|
||||||
worker: None,
|
|
||||||
diagnostics,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => CompanionWorkerState {
|
|
||||||
state: CompanionState::Error,
|
|
||||||
worker: None,
|
|
||||||
diagnostics: vec![diagnostic(
|
|
||||||
"companion_worker_spawn_failed",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!("Workspace Companion Worker spawn failed: {error:?}"),
|
|
||||||
)],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn companion_profile_selector() -> ProfileSelector {
|
|
||||||
ProfileSelector::Builtin(COMPANION_PROFILE_ID.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn companion_config_bundle() -> ConfigBundle {
|
|
||||||
ConfigBundle {
|
|
||||||
metadata: ConfigBundleMetadata {
|
|
||||||
id: COMPANION_CONFIG_BUNDLE_ID.to_string(),
|
|
||||||
digest: String::new(),
|
|
||||||
revision: "1".to_string(),
|
|
||||||
workspace_id: "workspace-companion".to_string(),
|
|
||||||
created_at: Utc::now().to_rfc3339(),
|
|
||||||
provenance: ConfigBundleProvenance {
|
|
||||||
source: "workspace-server".to_string(),
|
|
||||||
detail: Some("workspace-companion".to_string()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
profiles: vec![ConfigProfileDescriptor {
|
|
||||||
selector: companion_profile_selector(),
|
|
||||||
label: Some("Workspace Companion".to_string()),
|
|
||||||
}],
|
|
||||||
declarations: Vec::new(),
|
|
||||||
profile_source_archive: Some(companion_profile_archive()),
|
|
||||||
profile_source_archive_handle: None,
|
|
||||||
}
|
|
||||||
.with_computed_digest()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn companion_profile_archive() -> ProfileSourceArchive {
|
|
||||||
let mut entrypoints = BTreeMap::new();
|
|
||||||
entrypoints.insert("default".to_string(), "profiles/companion.dcdl".to_string());
|
|
||||||
entrypoints.insert(
|
|
||||||
COMPANION_PROFILE_ID.to_string(),
|
|
||||||
"profiles/companion.dcdl".to_string(),
|
|
||||||
);
|
|
||||||
let mut sources = BTreeMap::new();
|
|
||||||
sources.insert(
|
|
||||||
"profiles/companion.dcdl".to_string(),
|
|
||||||
include_str!("../../../resources/profiles/companion.dcdl").to_string(),
|
|
||||||
);
|
|
||||||
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
|
||||||
id: "workspace-companion-profile-archive-v1".to_string(),
|
|
||||||
entrypoints,
|
|
||||||
imports: BTreeMap::new(),
|
|
||||||
sources,
|
|
||||||
})
|
|
||||||
.expect("builtin Companion Decodal profile source archive is valid")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState {
|
|
||||||
if !worker.capabilities.can_accept_input {
|
|
||||||
return CompanionState::Error;
|
|
||||||
}
|
|
||||||
match worker.status.as_str() {
|
|
||||||
"busy" | "running" | "stopping" => CompanionState::Busy,
|
|
||||||
"errored" | "error" | "stopped" | "unavailable" => CompanionState::Error,
|
|
||||||
_ => CompanionState::Ready,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn companion_not_input_capable_diagnostic(worker: &WorkerSummary) -> RuntimeDiagnostic {
|
|
||||||
diagnostic(
|
|
||||||
"companion_worker_not_input_capable",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!(
|
|
||||||
"Workspace Companion Worker '{}' is not input-capable; check profile, provider, secret, and authority diagnostics",
|
|
||||||
worker.worker_id
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn project_runtime_transcript(
|
|
||||||
transcript: &WorkerTranscriptProjection,
|
|
||||||
state: CompanionState,
|
|
||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
) -> CompanionTranscriptProjection {
|
|
||||||
CompanionTranscriptProjection {
|
|
||||||
state,
|
state,
|
||||||
start: transcript.start,
|
worker: None,
|
||||||
limit: transcript.limit,
|
user_item: None,
|
||||||
total_items: transcript.total_items,
|
assistant_item: None,
|
||||||
next_start: transcript.next_start,
|
transcript: CompanionTranscriptProjection {
|
||||||
items: transcript
|
state: CompanionState::Disabled,
|
||||||
.items
|
start: 0,
|
||||||
.iter()
|
limit: 200,
|
||||||
.map(project_runtime_transcript_item)
|
total_items: 0,
|
||||||
.collect(),
|
next_start: None,
|
||||||
diagnostics,
|
items: Vec::new(),
|
||||||
|
diagnostics: vec![disabled_diagnostic()],
|
||||||
|
},
|
||||||
|
diagnostics: vec![disabled_diagnostic()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_runtime_transcript_item(item: &RuntimeTranscriptItem) -> CompanionTranscriptItem {
|
fn disabled_transport() -> CompanionTransportSummary {
|
||||||
CompanionTranscriptItem {
|
|
||||||
sequence: item.sequence,
|
|
||||||
role: item.role.clone(),
|
|
||||||
content: item.content.clone(),
|
|
||||||
created_at: format!("runtime_sequence:{}", item.sequence),
|
|
||||||
source: "worker_runtime".to_string(),
|
|
||||||
status: "committed".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn companion_transport(worker: Option<&WorkerSummary>) -> CompanionTransportSummary {
|
|
||||||
if worker.is_some_and(|worker| worker.capabilities.can_accept_input) {
|
|
||||||
CompanionTransportSummary {
|
CompanionTransportSummary {
|
||||||
kind: "embedded_worker_runtime".to_string(),
|
kind: "none".to_string(),
|
||||||
completion: "connected".to_string(),
|
completion: "disabled".to_string(),
|
||||||
limitation:
|
limitation:
|
||||||
"Workspace Companion input is dispatched through the normal Worker runtime path."
|
"Workspace Companion auto-start has been removed; create an explicit Worker instead."
|
||||||
.to_string(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CompanionTransportSummary {
|
|
||||||
kind: "embedded_worker_runtime".to_string(),
|
|
||||||
completion: "not_input_capable".to_string(),
|
|
||||||
limitation:
|
|
||||||
"Workspace Companion is a Worker but is not input-capable; inspect typed diagnostics for missing profile, provider, secret, or authority."
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn diagnostic(
|
fn disabled_diagnostic() -> RuntimeDiagnostic {
|
||||||
code: impl Into<String>,
|
|
||||||
severity: DiagnosticSeverity,
|
|
||||||
message: impl Into<String>,
|
|
||||||
) -> RuntimeDiagnostic {
|
|
||||||
RuntimeDiagnostic {
|
RuntimeDiagnostic {
|
||||||
code: code.into(),
|
code: "companion_disabled".to_string(),
|
||||||
severity,
|
severity: DiagnosticSeverity::Info,
|
||||||
message: message.into(),
|
message: "Workspace Companion auto-start is disabled; create an explicit Worker instead."
|
||||||
}
|
.to_string(),
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::hosts::{EmbeddedWorkerRuntime, RuntimeRegistry};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex as StdMutex;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use worker_runtime::execution::{
|
|
||||||
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
|
|
||||||
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionRunState,
|
|
||||||
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
|
||||||
};
|
|
||||||
use worker_runtime::identity::WorkerRef;
|
|
||||||
use worker_runtime::interaction::WorkerInput;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct DeterministicExecutionBackend {
|
|
||||||
contexts: StdMutex<HashMap<WorkerRef, WorkerExecutionContext>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkerExecutionBackend for DeterministicExecutionBackend {
|
|
||||||
fn backend_id(&self) -> &str {
|
|
||||||
"deterministic-companion-test"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
|
||||||
self.contexts
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(request.worker_ref.clone(), request.context);
|
|
||||||
WorkerExecutionSpawnResult::Connected {
|
|
||||||
handle: WorkerExecutionHandle::new(
|
|
||||||
request.worker_ref.clone(),
|
|
||||||
"deterministic-companion-test",
|
|
||||||
),
|
|
||||||
run_state: WorkerExecutionRunState::Idle,
|
|
||||||
working_directory: request
|
|
||||||
.working_directory
|
|
||||||
.as_ref()
|
|
||||||
.map(|binding| binding.status()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dispatch_input(
|
|
||||||
&self,
|
|
||||||
handle: &WorkerExecutionHandle,
|
|
||||||
request: WorkerInput,
|
|
||||||
) -> WorkerExecutionResult {
|
|
||||||
let worker = handle.worker_ref().clone();
|
|
||||||
let context = self
|
|
||||||
.contexts
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.get(&worker)
|
|
||||||
.cloned()
|
|
||||||
.expect("execution context");
|
|
||||||
let content = request.content.clone();
|
|
||||||
thread::spawn(move || {
|
|
||||||
thread::sleep(Duration::from_millis(25));
|
|
||||||
let _ = context.publish_protocol_event(protocol::Event::TextDone {
|
|
||||||
text: format!("companion echoed: {content}"),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
WorkerExecutionResult::accepted(
|
|
||||||
WorkerExecutionOperation::Input,
|
|
||||||
WorkerExecutionRunState::Idle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn companion_spawns_worker_with_companion_profile_through_runtime_backend() {
|
|
||||||
let registry = RuntimeRegistry::for_workspace(
|
|
||||||
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
|
||||||
"local:test",
|
|
||||||
Arc::new(DeterministicExecutionBackend::default()),
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let registry = Arc::new(registry);
|
|
||||||
let companion = CompanionConsole::new(registry.clone());
|
|
||||||
|
|
||||||
let status = companion.status();
|
|
||||||
let worker = status.worker.clone().expect("companion worker");
|
|
||||||
assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID);
|
|
||||||
assert_eq!(worker.role.as_deref(), Some(COMPANION_PROFILE_ID));
|
|
||||||
assert!(worker.capabilities.can_accept_input);
|
|
||||||
assert_eq!(status.transport.completion, "connected");
|
|
||||||
assert!(status.diagnostics.is_empty());
|
|
||||||
|
|
||||||
let response = companion.send_message(CompanionMessageRequest {
|
|
||||||
content: "hello".to_string(),
|
|
||||||
});
|
|
||||||
assert_eq!(response.state, CompanionState::Accepted);
|
|
||||||
assert!(response.diagnostics.is_empty());
|
|
||||||
assert!(
|
|
||||||
response
|
|
||||||
.transcript
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.any(|entry| entry.role == "user" && entry.content == "hello")
|
|
||||||
);
|
|
||||||
|
|
||||||
let worker_detail = registry
|
|
||||||
.worker(COMPANION_RUNTIME_ID, &worker.worker_id)
|
|
||||||
.expect("worker detail");
|
|
||||||
assert_eq!(worker_detail.profile.as_deref(), Some(COMPANION_PROFILE_ID));
|
|
||||||
|
|
||||||
let browser_payload = serde_json::to_string(&(status, response, worker_detail)).unwrap();
|
|
||||||
for forbidden in [
|
|
||||||
"/workspace/project",
|
|
||||||
"metadata.json",
|
|
||||||
".jsonl",
|
|
||||||
"/run/user/",
|
|
||||||
"session",
|
|
||||||
"manifest",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!browser_payload.contains(forbidden),
|
|
||||||
"companion projection leaked forbidden term {forbidden}: {browser_payload}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn companion_dispatches_input_and_projects_assistant_output_from_worker_runtime() {
|
|
||||||
let registry = RuntimeRegistry::for_workspace(
|
|
||||||
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
|
||||||
"local:test",
|
|
||||||
Arc::new(DeterministicExecutionBackend::default()),
|
|
||||||
)
|
|
||||||
.expect("embedded runtime"),
|
|
||||||
);
|
|
||||||
let registry = Arc::new(registry);
|
|
||||||
let companion = CompanionConsole::new(registry.clone());
|
|
||||||
let status = companion.status();
|
|
||||||
let worker = status.worker.clone().expect("companion worker");
|
|
||||||
assert_eq!(status.transport.completion, "connected");
|
|
||||||
assert_eq!(worker.profile.as_deref(), Some(COMPANION_PROFILE_ID));
|
|
||||||
assert!(worker.capabilities.can_accept_input);
|
|
||||||
|
|
||||||
let source = registry
|
|
||||||
.observation_source(COMPANION_RUNTIME_ID, &worker.worker_id)
|
|
||||||
.expect("observation source");
|
|
||||||
let crate::observation::RuntimeObservationSource::Embedded(source) = source else {
|
|
||||||
panic!("expected embedded observation source");
|
|
||||||
};
|
|
||||||
let cursor = source
|
|
||||||
.runtime
|
|
||||||
.worker_observation_cursor_now(&source.worker_ref)
|
|
||||||
.expect("observation cursor");
|
|
||||||
|
|
||||||
let response = companion.send_message(CompanionMessageRequest {
|
|
||||||
content: "hello runtime".to_string(),
|
|
||||||
});
|
|
||||||
assert_eq!(response.state, CompanionState::Accepted);
|
|
||||||
assert!(
|
|
||||||
response
|
|
||||||
.user_item
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|item| item.role == "user" && item.content == "hello runtime")
|
|
||||||
);
|
|
||||||
assert!(response.diagnostics.is_empty());
|
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(2);
|
|
||||||
let observed = loop {
|
|
||||||
let observed = source
|
|
||||||
.runtime
|
|
||||||
.read_worker_observation_events(&source.worker_ref, cursor)
|
|
||||||
.expect("observation events");
|
|
||||||
if observed.iter().any(|event| {
|
|
||||||
serde_json::to_string(event)
|
|
||||||
.unwrap()
|
|
||||||
.contains("companion echoed: hello runtime")
|
|
||||||
}) {
|
|
||||||
break observed;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
Instant::now() < deadline,
|
|
||||||
"timed out waiting for observation event"
|
|
||||||
);
|
|
||||||
thread::sleep(Duration::from_millis(20));
|
|
||||||
};
|
|
||||||
let observed_json = serde_json::to_string(&observed).unwrap();
|
|
||||||
assert!(observed_json.contains("companion echoed: hello runtime"));
|
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(2);
|
|
||||||
let transcript = loop {
|
|
||||||
let transcript = companion.transcript(0, 20);
|
|
||||||
if transcript.items.iter().any(|item| {
|
|
||||||
item.role == "assistant" && item.content == "companion echoed: hello runtime"
|
|
||||||
}) {
|
|
||||||
break transcript;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
Instant::now() < deadline,
|
|
||||||
"timed out waiting for companion assistant output: {transcript:?}"
|
|
||||||
);
|
|
||||||
thread::sleep(Duration::from_millis(20));
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
transcript
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.any(|item| item.role == "user" && item.content == "hello runtime")
|
|
||||||
);
|
|
||||||
assert!(transcript.items.iter().any(|item| {
|
|
||||||
item.role == "assistant"
|
|
||||||
&& item.source == "worker_runtime"
|
|
||||||
&& item.status == "committed"
|
|
||||||
}));
|
|
||||||
|
|
||||||
let runtime_transcript = registry
|
|
||||||
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 20)
|
|
||||||
.expect("runtime transcript");
|
|
||||||
assert!(runtime_transcript.items.iter().any(|item| {
|
|
||||||
item.role == "assistant" && item.content == "companion echoed: hello runtime"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use worker_runtime::identity::WorkerRef;
|
use worker_runtime::identity::WorkerRef;
|
||||||
use worker_runtime::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
use worker_runtime::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
||||||
|
|
@ -100,7 +100,7 @@ impl std::fmt::Debug for RuntimeObservationSource {
|
||||||
pub struct RuntimeObservationUpstreamEvent {
|
pub struct RuntimeObservationUpstreamEvent {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
pub runtime_cursor: String,
|
pub runtime_event_id: String,
|
||||||
pub payload: protocol::Event,
|
pub payload: protocol::Event,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,11 +116,10 @@ pub enum ClientWorkerEventWsFrame {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backend-owned opaque event envelope. It intentionally omits Runtime endpoints,
|
/// Backend-owned event envelope. It intentionally omits Runtime endpoints,
|
||||||
/// credentials, sockets and session paths.
|
/// credentials, sockets and session paths.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ClientWorkerEventWsEnvelope {
|
pub struct ClientWorkerEventWsEnvelope {
|
||||||
pub cursor: String,
|
|
||||||
pub event_id: String,
|
pub event_id: String,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
|
|
@ -134,17 +133,10 @@ pub struct ClientWorkerEventWsDiagnostic {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
|
||||||
pub struct ClientWorkerEventsWsQuery {
|
|
||||||
pub cursor: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum ObservationProxyError {
|
pub enum ObservationProxyError {
|
||||||
RuntimeUnavailable(String),
|
RuntimeUnavailable(String),
|
||||||
WorkerNotFound(String),
|
WorkerNotFound(String),
|
||||||
CursorMalformed(String),
|
|
||||||
CursorUnknownOrExpired(String),
|
|
||||||
UpstreamDisconnect(String),
|
UpstreamDisconnect(String),
|
||||||
MalformedFrame(String),
|
MalformedFrame(String),
|
||||||
ObservationOnly,
|
ObservationOnly,
|
||||||
|
|
@ -155,8 +147,6 @@ impl ObservationProxyError {
|
||||||
match self {
|
match self {
|
||||||
ObservationProxyError::RuntimeUnavailable(_) => "backend.runtime_unavailable",
|
ObservationProxyError::RuntimeUnavailable(_) => "backend.runtime_unavailable",
|
||||||
ObservationProxyError::WorkerNotFound(_) => "backend.worker_not_found",
|
ObservationProxyError::WorkerNotFound(_) => "backend.worker_not_found",
|
||||||
ObservationProxyError::CursorMalformed(_) => "backend.cursor_malformed",
|
|
||||||
ObservationProxyError::CursorUnknownOrExpired(_) => "backend.cursor_unknown_or_expired",
|
|
||||||
ObservationProxyError::UpstreamDisconnect(_) => "backend.upstream_disconnect",
|
ObservationProxyError::UpstreamDisconnect(_) => "backend.upstream_disconnect",
|
||||||
ObservationProxyError::MalformedFrame(_) => "backend.malformed_frame",
|
ObservationProxyError::MalformedFrame(_) => "backend.malformed_frame",
|
||||||
ObservationProxyError::ObservationOnly => "backend.observation_only",
|
ObservationProxyError::ObservationOnly => "backend.observation_only",
|
||||||
|
|
@ -167,8 +157,6 @@ impl ObservationProxyError {
|
||||||
match self {
|
match self {
|
||||||
ObservationProxyError::RuntimeUnavailable(message)
|
ObservationProxyError::RuntimeUnavailable(message)
|
||||||
| ObservationProxyError::WorkerNotFound(message)
|
| ObservationProxyError::WorkerNotFound(message)
|
||||||
| ObservationProxyError::CursorMalformed(message)
|
|
||||||
| ObservationProxyError::CursorUnknownOrExpired(message)
|
|
||||||
| ObservationProxyError::UpstreamDisconnect(message)
|
| ObservationProxyError::UpstreamDisconnect(message)
|
||||||
| ObservationProxyError::MalformedFrame(message) => message,
|
| ObservationProxyError::MalformedFrame(message) => message,
|
||||||
ObservationProxyError::ObservationOnly => {
|
ObservationProxyError::ObservationOnly => {
|
||||||
|
|
@ -193,82 +181,22 @@ impl ClientWorkerEventWsFrame {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct BackendObservationCursor {
|
|
||||||
pub sequence: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendObservationCursor {
|
|
||||||
pub fn new(sequence: u64) -> Self {
|
|
||||||
Self { sequence }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn zero() -> Self {
|
|
||||||
Self { sequence: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode(self) -> String {
|
|
||||||
format!("bo_{:016x}", self.sequence)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode(value: &str) -> Option<Self> {
|
|
||||||
let encoded = value.strip_prefix("bo_")?;
|
|
||||||
if encoded.len() != 16 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
u64::from_str_radix(encoded, 16)
|
|
||||||
.ok()
|
|
||||||
.map(|sequence| Self { sequence })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct BackendObservationState {
|
|
||||||
next_sequence: u64,
|
|
||||||
history: BTreeMap<ObservationKey, VecDeque<StoredBackendEvent>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendObservationState {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
next_sequence: 1,
|
|
||||||
history: BTreeMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
struct ObservationKey {
|
struct ObservationKey {
|
||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
worker_id: String,
|
worker_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct StoredBackendEvent {
|
|
||||||
sequence: u64,
|
|
||||||
runtime_cursor: String,
|
|
||||||
envelope: ClientWorkerEventWsEnvelope,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct BackendObservationOpen {
|
|
||||||
pub replay: Vec<ClientWorkerEventWsEnvelope>,
|
|
||||||
pub runtime_cursor: Option<String>,
|
|
||||||
pub backend_cursor: BackendObservationCursor,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backend-owned in-memory v0 observation proxy state.
|
/// Backend-owned in-memory v0 observation proxy state.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct BackendObservationProxy {
|
pub struct BackendObservationProxy {
|
||||||
sources: Arc<BTreeMap<ObservationKey, RuntimeObservationSourceConfig>>,
|
sources: Arc<BTreeMap<ObservationKey, RuntimeObservationSourceConfig>>,
|
||||||
state: Arc<Mutex<BackendObservationState>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for BackendObservationProxy {
|
impl std::fmt::Debug for BackendObservationProxy {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("BackendObservationProxy")
|
f.debug_struct("BackendObservationProxy")
|
||||||
.field("source_count", &self.sources.len())
|
.field("source_count", &self.sources.len())
|
||||||
.field("state", &"<omitted>")
|
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -289,7 +217,6 @@ impl BackendObservationProxy {
|
||||||
.collect();
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
sources: Arc::new(sources),
|
sources: Arc::new(sources),
|
||||||
state: Arc::new(Mutex::new(BackendObservationState::new())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,99 +239,13 @@ impl BackendObservationProxy {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open(
|
pub fn map_event(&self, event: RuntimeObservationUpstreamEvent) -> ClientWorkerEventWsEnvelope {
|
||||||
&self,
|
ClientWorkerEventWsEnvelope {
|
||||||
runtime_id: &str,
|
event_id: event.runtime_event_id,
|
||||||
worker_id: &str,
|
|
||||||
cursor: Option<&str>,
|
|
||||||
) -> Result<BackendObservationOpen, ObservationProxyError> {
|
|
||||||
let key = ObservationKey {
|
|
||||||
runtime_id: runtime_id.to_string(),
|
|
||||||
worker_id: worker_id.to_string(),
|
|
||||||
};
|
|
||||||
let cursor = match cursor {
|
|
||||||
Some(raw) => BackendObservationCursor::decode(raw).ok_or_else(|| {
|
|
||||||
ObservationProxyError::CursorMalformed(format!(
|
|
||||||
"malformed backend observation cursor: {raw}"
|
|
||||||
))
|
|
||||||
})?,
|
|
||||||
None => BackendObservationCursor::zero(),
|
|
||||||
};
|
|
||||||
let state = self.state.lock().map_err(|_| {
|
|
||||||
ObservationProxyError::RuntimeUnavailable(
|
|
||||||
"backend observation state lock poisoned".into(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let history = state.history.get(&key);
|
|
||||||
let replay: Vec<_> = history
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(|events| events.iter())
|
|
||||||
.filter(|event| event.sequence > cursor.sequence)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
if cursor.sequence != 0 {
|
|
||||||
let found = history
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(|events| events.iter())
|
|
||||||
.any(|event| event.sequence == cursor.sequence);
|
|
||||||
if !found {
|
|
||||||
return Err(ObservationProxyError::CursorUnknownOrExpired(format!(
|
|
||||||
"backend observation cursor {} is unknown or expired for runtime {runtime_id} worker {worker_id}",
|
|
||||||
cursor.encode()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let runtime_cursor = replay
|
|
||||||
.last()
|
|
||||||
.map(|event| event.runtime_cursor.clone())
|
|
||||||
.or_else(|| {
|
|
||||||
history.and_then(|events| {
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.find(|event| event.sequence == cursor.sequence)
|
|
||||||
.map(|event| event.runtime_cursor.clone())
|
|
||||||
})
|
|
||||||
});
|
|
||||||
Ok(BackendObservationOpen {
|
|
||||||
replay: replay.into_iter().map(|event| event.envelope).collect(),
|
|
||||||
runtime_cursor,
|
|
||||||
backend_cursor: cursor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn store(
|
|
||||||
&self,
|
|
||||||
event: RuntimeObservationUpstreamEvent,
|
|
||||||
) -> Result<ClientWorkerEventWsEnvelope, ObservationProxyError> {
|
|
||||||
let mut state = self.state.lock().map_err(|_| {
|
|
||||||
ObservationProxyError::RuntimeUnavailable(
|
|
||||||
"backend observation state lock poisoned".into(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let sequence = state.next_sequence;
|
|
||||||
state.next_sequence += 1;
|
|
||||||
let cursor = BackendObservationCursor::new(sequence).encode();
|
|
||||||
let envelope = ClientWorkerEventWsEnvelope {
|
|
||||||
cursor: cursor.clone(),
|
|
||||||
event_id: cursor,
|
|
||||||
runtime_id: event.runtime_id.clone(),
|
|
||||||
worker_id: event.worker_id.clone(),
|
|
||||||
payload: event.payload,
|
|
||||||
};
|
|
||||||
let key = ObservationKey {
|
|
||||||
runtime_id: event.runtime_id,
|
runtime_id: event.runtime_id,
|
||||||
worker_id: event.worker_id,
|
worker_id: event.worker_id,
|
||||||
};
|
payload: event.payload,
|
||||||
let history = state.history.entry(key).or_default();
|
|
||||||
history.push_back(StoredBackendEvent {
|
|
||||||
sequence,
|
|
||||||
runtime_cursor: event.runtime_cursor,
|
|
||||||
envelope: envelope.clone(),
|
|
||||||
});
|
|
||||||
while history.len() > 1024 {
|
|
||||||
history.pop_front();
|
|
||||||
}
|
}
|
||||||
Ok(envelope)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,11 +256,6 @@ fn map_runtime_connect_error(error: TungsteniteError) -> ObservationProxyError {
|
||||||
"runtime worker observation endpoint returned 404 not found".into(),
|
"runtime worker observation endpoint returned 404 not found".into(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TungsteniteError::Http(response) if response.status() == StatusCode::BAD_REQUEST => {
|
|
||||||
ObservationProxyError::CursorMalformed(
|
|
||||||
"runtime worker observation endpoint rejected the request as malformed".into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TungsteniteError::Http(response) => ObservationProxyError::RuntimeUnavailable(format!(
|
TungsteniteError::Http(response) => ObservationProxyError::RuntimeUnavailable(format!(
|
||||||
"runtime worker observation endpoint rejected WebSocket upgrade with status {}",
|
"runtime worker observation endpoint rejected WebSocket upgrade with status {}",
|
||||||
response.status()
|
response.status()
|
||||||
|
|
@ -433,10 +269,9 @@ fn map_runtime_connect_error(error: TungsteniteError) -> ObservationProxyError {
|
||||||
fn map_runtime_diagnostic(code: String, message: String) -> ObservationProxyError {
|
fn map_runtime_diagnostic(code: String, message: String) -> ObservationProxyError {
|
||||||
match code.as_str() {
|
match code.as_str() {
|
||||||
"runtime.worker_not_found" => ObservationProxyError::WorkerNotFound(message),
|
"runtime.worker_not_found" => ObservationProxyError::WorkerNotFound(message),
|
||||||
"runtime.cursor_malformed" => ObservationProxyError::CursorMalformed(message),
|
"runtime.cursor_malformed"
|
||||||
"runtime.cursor_unknown_or_expired" | "runtime.cursor_expired" => {
|
| "runtime.cursor_unknown_or_expired"
|
||||||
ObservationProxyError::CursorUnknownOrExpired(message)
|
| "runtime.cursor_expired" => ObservationProxyError::RuntimeUnavailable(message),
|
||||||
}
|
|
||||||
"runtime.unavailable" => ObservationProxyError::RuntimeUnavailable(message),
|
"runtime.unavailable" => ObservationProxyError::RuntimeUnavailable(message),
|
||||||
"runtime.upstream_closed" | "runtime.websocket_error" => {
|
"runtime.upstream_closed" | "runtime.websocket_error" => {
|
||||||
ObservationProxyError::UpstreamDisconnect(message)
|
ObservationProxyError::UpstreamDisconnect(message)
|
||||||
|
|
@ -460,15 +295,8 @@ pub struct RuntimeWsObservationClient {
|
||||||
impl RuntimeWsObservationClient {
|
impl RuntimeWsObservationClient {
|
||||||
pub async fn connect(
|
pub async fn connect(
|
||||||
source: &RuntimeObservationSourceConfig,
|
source: &RuntimeObservationSourceConfig,
|
||||||
runtime_cursor: Option<&str>,
|
|
||||||
) -> Result<Self, ObservationProxyError> {
|
) -> Result<Self, ObservationProxyError> {
|
||||||
let mut endpoint = source.endpoint.clone();
|
let endpoint = source.endpoint.clone();
|
||||||
if let Some(cursor) = runtime_cursor {
|
|
||||||
let separator = if endpoint.contains('?') { '&' } else { '?' };
|
|
||||||
endpoint.push(separator);
|
|
||||||
endpoint.push_str("cursor=");
|
|
||||||
endpoint.push_str(cursor);
|
|
||||||
}
|
|
||||||
let mut request = endpoint.into_client_request().map_err(|error| {
|
let mut request = endpoint.into_client_request().map_err(|error| {
|
||||||
ObservationProxyError::RuntimeUnavailable(format!(
|
ObservationProxyError::RuntimeUnavailable(format!(
|
||||||
"failed to build runtime WebSocket request: {error}"
|
"failed to build runtime WebSocket request: {error}"
|
||||||
|
|
@ -557,7 +385,7 @@ impl RuntimeWsObservationClient {
|
||||||
RuntimeObservationUpstreamEvent {
|
RuntimeObservationUpstreamEvent {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
worker_id: self.worker_id.clone(),
|
worker_id: self.worker_id.clone(),
|
||||||
runtime_cursor: envelope.cursor,
|
runtime_event_id: envelope.event_id,
|
||||||
payload: envelope.payload,
|
payload: envelope.payload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -569,18 +397,15 @@ pub enum RuntimeObservationClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeObservationClient {
|
impl RuntimeObservationClient {
|
||||||
pub async fn connect(
|
pub async fn connect(source: &RuntimeObservationSource) -> Result<Self, ObservationProxyError> {
|
||||||
source: &RuntimeObservationSource,
|
|
||||||
runtime_cursor: Option<&str>,
|
|
||||||
) -> Result<Self, ObservationProxyError> {
|
|
||||||
match source {
|
match source {
|
||||||
RuntimeObservationSource::RemoteWs(config) => {
|
RuntimeObservationSource::RemoteWs(config) => {
|
||||||
RuntimeWsObservationClient::connect(config, runtime_cursor)
|
RuntimeWsObservationClient::connect(config)
|
||||||
.await
|
.await
|
||||||
.map(Self::RemoteWs)
|
.map(Self::RemoteWs)
|
||||||
}
|
}
|
||||||
RuntimeObservationSource::Embedded(source) => {
|
RuntimeObservationSource::Embedded(source) => {
|
||||||
EmbeddedObservationClient::connect(source, runtime_cursor).map(Self::Embedded)
|
EmbeddedObservationClient::connect(source).map(Self::Embedded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -605,17 +430,8 @@ pub struct EmbeddedObservationClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EmbeddedObservationClient {
|
impl EmbeddedObservationClient {
|
||||||
fn connect(
|
fn connect(source: &EmbeddedRuntimeObservationSource) -> Result<Self, ObservationProxyError> {
|
||||||
source: &EmbeddedRuntimeObservationSource,
|
let cursor = source
|
||||||
runtime_cursor: Option<&str>,
|
|
||||||
) -> Result<Self, ObservationProxyError> {
|
|
||||||
let cursor = match runtime_cursor {
|
|
||||||
Some(raw) => WorkerObservationCursor::decode(raw).ok_or_else(|| {
|
|
||||||
ObservationProxyError::CursorMalformed(
|
|
||||||
"embedded runtime cursor is malformed".into(),
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
None => source
|
|
||||||
.runtime
|
.runtime
|
||||||
.worker_observation_cursor_now(&source.worker_ref)
|
.worker_observation_cursor_now(&source.worker_ref)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
|
|
@ -623,8 +439,7 @@ impl EmbeddedObservationClient {
|
||||||
"embedded Worker '{}' is not observable: {err}",
|
"embedded Worker '{}' is not observable: {err}",
|
||||||
source.worker_id
|
source.worker_id
|
||||||
))
|
))
|
||||||
})?,
|
})?;
|
||||||
};
|
|
||||||
let receiver = source
|
let receiver = source
|
||||||
.runtime
|
.runtime
|
||||||
.subscribe_worker_observation()
|
.subscribe_worker_observation()
|
||||||
|
|
@ -635,7 +450,6 @@ impl EmbeddedObservationClient {
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let mut queued = VecDeque::new();
|
let mut queued = VecDeque::new();
|
||||||
if runtime_cursor.is_none() {
|
|
||||||
let snapshot = source
|
let snapshot = source
|
||||||
.runtime
|
.runtime
|
||||||
.worker_observation_snapshot(&source.worker_ref)
|
.worker_observation_snapshot(&source.worker_ref)
|
||||||
|
|
@ -648,16 +462,15 @@ impl EmbeddedObservationClient {
|
||||||
queued.push_back(RuntimeObservationUpstreamEvent {
|
queued.push_back(RuntimeObservationUpstreamEvent {
|
||||||
runtime_id: source.runtime_id.clone(),
|
runtime_id: source.runtime_id.clone(),
|
||||||
worker_id: source.worker_id.clone(),
|
worker_id: source.worker_id.clone(),
|
||||||
runtime_cursor: cursor.encode(),
|
runtime_event_id: "snapshot".to_string(),
|
||||||
payload: snapshot,
|
payload: snapshot,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
for event in source
|
for event in source
|
||||||
.runtime
|
.runtime
|
||||||
.read_worker_observation_events(&source.worker_ref, cursor)
|
.read_worker_observation_events(&source.worker_ref, cursor)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
ObservationProxyError::CursorUnknownOrExpired(format!(
|
ObservationProxyError::RuntimeUnavailable(format!(
|
||||||
"embedded Worker '{}' cursor is unavailable: {err}",
|
"embedded Worker '{}' observation cursor is unavailable: {err}",
|
||||||
source.worker_id
|
source.worker_id
|
||||||
))
|
))
|
||||||
})?
|
})?
|
||||||
|
|
@ -682,9 +495,6 @@ impl EmbeddedObservationClient {
|
||||||
&mut self,
|
&mut self,
|
||||||
) -> Result<RuntimeObservationUpstreamEvent, ObservationProxyError> {
|
) -> Result<RuntimeObservationUpstreamEvent, ObservationProxyError> {
|
||||||
if let Some(event) = self.queued.pop_front() {
|
if let Some(event) = self.queued.pop_front() {
|
||||||
if let Some(cursor) = WorkerObservationCursor::decode(&event.runtime_cursor) {
|
|
||||||
self.cursor = cursor;
|
|
||||||
}
|
|
||||||
return Ok(event);
|
return Ok(event);
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -695,7 +505,7 @@ impl EmbeddedObservationClient {
|
||||||
{
|
{
|
||||||
self.cursor =
|
self.cursor =
|
||||||
WorkerObservationCursor::decode(&event.cursor).ok_or_else(|| {
|
WorkerObservationCursor::decode(&event.cursor).ok_or_else(|| {
|
||||||
ObservationProxyError::CursorMalformed(
|
ObservationProxyError::RuntimeUnavailable(
|
||||||
"embedded runtime emitted a malformed cursor".into(),
|
"embedded runtime emitted a malformed cursor".into(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -703,7 +513,7 @@ impl EmbeddedObservationClient {
|
||||||
}
|
}
|
||||||
Ok(_) => continue,
|
Ok(_) => continue,
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||||
return Err(ObservationProxyError::CursorUnknownOrExpired(
|
return Err(ObservationProxyError::RuntimeUnavailable(
|
||||||
"embedded runtime observation backlog was exceeded".into(),
|
"embedded runtime observation backlog was exceeded".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -724,7 +534,7 @@ impl EmbeddedObservationClient {
|
||||||
RuntimeObservationUpstreamEvent {
|
RuntimeObservationUpstreamEvent {
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id: worker_id.to_string(),
|
worker_id: worker_id.to_string(),
|
||||||
runtime_cursor: event.cursor,
|
runtime_event_id: event.cursor.clone(),
|
||||||
payload: event.payload,
|
payload: event.payload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -68,6 +68,18 @@ impl BackendResourceBroker {
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn profile_source_archive(
|
||||||
|
&self,
|
||||||
|
digest: &str,
|
||||||
|
) -> Option<worker_runtime::profile_archive::ProfileSourceArchive> {
|
||||||
|
self.resources
|
||||||
|
.lock()
|
||||||
|
.ok()?
|
||||||
|
.values()
|
||||||
|
.find(|resource| resource.handle.digest == digest)
|
||||||
|
.map(|resource| resource.archive.clone())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn fetch_profile_source_archive(
|
pub fn fetch_profile_source_archive(
|
||||||
&self,
|
&self,
|
||||||
request: BackendResourceFetchRequest,
|
request: BackendResourceFetchRequest,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user