Compare commits

..

121 Commits

Author SHA1 Message Date
b6bb22f956
merge: orchestration 2026-07-11 13:44:15 +09:00
6823f0e72d
ticket: close worker lifecycle state removal 2026-07-11 13:34:08 +09:00
b877c92975
workspace: remove worker lifecycle state 2026-07-11 13:33:49 +09:00
13f9e0fab8
ticket: remove worker lifecycle state 2026-07-11 13:09:06 +09:00
7d3b364728
ticket: close workspace backend work 2026-07-11 10:10:05 +09:00
391f11fc23
merge: workspace backend worker context 2026-07-11 10:07:19 +09:00
5d4cb9b3fe
ticket: approve workspace backend implementation 2026-07-11 10:07:12 +09:00
0736de6451
ticket: record workspace backend implementation 2026-07-11 10:00:03 +09:00
142b60e1b3
refactor: separate worker workspace identity 2026-07-11 09:58:20 +09:00
a98e25eda8
ticket: close observation cursor removal 2026-07-11 09:31:57 +09:00
10762a3c8b
workspace: remove observation cursor surface 2026-07-11 09:31:49 +09:00
74b07bd787
ticket: accept workspace backend work 2026-07-11 09:15:16 +09:00
14e704b858
ticket: close embedded no-workdir policy work 2026-07-11 09:13:58 +09:00
2f7b809401
merge: embedded no-workdir worker policy 2026-07-11 09:12:34 +09:00
50eec7885a
ticket: approve embedded no-workdir policy 2026-07-11 09:12:29 +09:00
54eaa44681
ticket: remove observation cursor surface 2026-07-11 09:09:33 +09:00
7223cab0b2
ticket: record embedded no-workdir implementation 2026-07-11 09:01:00 +09:00
10267869ae
feat: enforce embedded no-workdir worker policy 2026-07-11 08:59:19 +09:00
1d7765d9ee
ticket: close observation replay removal 2026-07-11 08:50:55 +09:00
7d393f8c98
ticket: finish observation replay removal 2026-07-11 08:50:55 +09:00
8a3fca65f2
workspace: remove observation replay 2026-07-11 08:50:42 +09:00
83c9d8844d
ticket: route embedded no-workdir worker work 2026-07-11 08:38:59 +09:00
3a0600be0f
ticket: remove observation replay 2026-07-11 08:35:04 +09:00
0e01e5c84b
workspace: align console colors with tui 2026-07-11 08:18:44 +09:00
69607d02bc
workspace: remove console color rail 2026-07-11 08:05:28 +09:00
33ae73e7ee
ticket: close rich console rendering 2026-07-11 07:46:12 +09:00
db5c1d64d1
workspace: add rich console rendering 2026-07-11 07:46:03 +09:00
0f2d7263a8
ticket: close worker filesystem authority work 2026-07-11 07:45:08 +09:00
c29e913511
merge: worker filesystem authority 2026-07-11 07:40:45 +09:00
19a52742a3
ticket: approve worker filesystem authority implementation 2026-07-11 07:40:38 +09:00
5d07e9b9d5
ticket: console rich rendering 2026-07-11 07:31:41 +09:00
44410a0fdd
ticket: record worker filesystem authority implementation 2026-07-11 07:31:20 +09:00
b50b94612a
feat: add explicit worker filesystem authority 2026-07-11 07:30:17 +09:00
89ba205cac
workspace: aggregate console read calls 2026-07-11 07:28:31 +09:00
dbc223e9e0
ticket: close console tool call rendering 2026-07-11 07:17:10 +09:00
2a6930a946
workspace: group console tool calls 2026-07-11 07:17:03 +09:00
a5417320eb
ticket: console tool call rendering 2026-07-11 07:09:21 +09:00
a38fcdf04d
ticket: hold workspace backend work for authority base 2026-07-11 07:04:02 +09:00
4b2e03d7cc
ticket: queue 00001KX6Y6ZEA 2026-07-11 07:03:28 +09:00
603a717e40
merge: sync orchestration before queue 00001KX6Y6ZEA 2026-07-11 07:03:27 +09:00
e6b5013224
ticket: ready worker workspace backend 2026-07-11 07:01:46 +09:00
3ee9469c1a
ticket: hold embedded no-workdir policy for authority base 2026-07-11 07:00:37 +09:00
bbcc91db01
ticket: require workspace root removal 2026-07-11 07:00:25 +09:00
366af8c544
ticket: queue 00001KX6WVNPD 2026-07-11 07:00:07 +09:00
505bbd7cc9
merge: sync orchestration before queue 00001KX6WVNPD 2026-07-11 07:00:07 +09:00
e4653f7a2d
ticket: ready embedded no-workdir worker 2026-07-11 06:59:20 +09:00
f7191056b7
ticket: align embedded policy with filesystem authority 2026-07-11 06:54:22 +09:00
db3a7165f7
ticket: accept worker filesystem authority work 2026-07-11 06:51:37 +09:00
5c228a7fae
ticket: queue 00001KX6Y2A9Q 2026-07-11 06:51:00 +09:00
e40445b07a
ticket: ready worker filesystem authority 2026-07-11 06:49:37 +09:00
3df1c2dcfa
ticket: require cwd authority removal 2026-07-11 06:46:25 +09:00
6fa3d9d21f
ticket: normalize worker authority priority 2026-07-11 06:34:18 +09:00
358fb32fe7
ticket: clarify worker workspace identity 2026-07-11 06:23:30 +09:00
632a3310ea
ticket: worker workspace backend 2026-07-11 06:16:53 +09:00
9d6400cf2f
ticket: worker filesystem authority 2026-07-11 06:14:22 +09:00
c44bdb286a
ticket: embedded no-workdir worker authority 2026-07-11 05:53:24 +09:00
cab6a5300d
merge: orchestration 2026-07-11 04:52:53 +09:00
254360f1ab
profile: remove lua profile resolver 2026-07-11 04:44:32 +09:00
156f8ad044
runtime: remove worker transcript projection 2026-07-11 03:55:44 +09:00
1262b6022a
ticket: close manual cleanup work 2026-07-11 03:37:44 +09:00
4970a58c5a
merge: manual worker workdir cleanup
# Conflicts:
#	.yoi/tickets/00001KX6CRVBE/item.md
#	.yoi/tickets/00001KX6CRVBE/thread.md
2026-07-11 03:36:24 +09:00
1b4d336d80
ticket: approve manual cleanup implementation 2026-07-11 03:36:14 +09:00
c961185635
ticket: record manual cleanup safety fix 2026-07-11 03:30:57 +09:00
361569a69e
fix: require discard confirmation for unknown workdirs 2026-07-11 03:30:08 +09:00
29ddc168a3
ticket: record manual cleanup review blocker 2026-07-11 03:19:16 +09:00
85da6a6c33
ticket: record manual cleanup implementation evidence 2026-07-11 03:13:44 +09:00
ba44391acf
feat: add manual worker workdir cleanup 2026-07-11 03:12:51 +09:00
5f762a6ce3
ticket: accept manual cleanup work 2026-07-11 02:39:10 +09:00
dcab396e69
ticket: close worker workdir registry work 2026-07-11 02:38:25 +09:00
03652f8210
merge: worker workdir registry
# Conflicts:
#	.yoi/tickets/00001KX6BPY7M/item.md
#	.yoi/tickets/00001KX6BPY7M/thread.md
2026-07-11 02:35:48 +09:00
763709fdf8
ticket: approve worker workdir registry implementation 2026-07-11 02:35:34 +09:00
26e4efc990
ticket: record unmanaged workdir fix pass 2026-07-11 02:32:07 +09:00
239f93b084
fix: keep unmanaged workdirs out of managed lists 2026-07-11 02:31:29 +09:00
d9b3eb0e8b
ticket: record unmanaged workdir review blocker 2026-07-11 02:22:18 +09:00
e6b52b045a
ticket: record worker workdir registry fix pass 2026-07-11 02:14:06 +09:00
8695089fba
fix: sync backend worker registry projections 2026-07-11 02:13:00 +09:00
522c7043b1
ticket: record worker workdir registry review blockers 2026-07-11 01:52:34 +09:00
210f41e020
objective: clarify worker cleanup retention 2026-07-11 01:48:49 +09:00
1ed0501f8e
ticket: hold cleanup work for registry review 2026-07-11 01:46:14 +09:00
234d62bbef
ticket: queue 00001KX6CRVBE 2026-07-11 01:45:14 +09:00
5f669b90ef
merge: sync orchestration before queue 00001KX6CRVBE 2026-07-11 01:45:14 +09:00
db8cfa8142
ticket: ready manual worker workdir cleanup 2026-07-11 01:44:28 +09:00
c949e1b4f2
ticket: simplify worker delete lifecycle 2026-07-11 01:43:46 +09:00
c2afaefb5f
ticket: record worker workdir registry implementation evidence 2026-07-11 01:40:24 +09:00
3e5546ea33
feat: add backend worker workdir registry 2026-07-11 01:38:23 +09:00
c88283dbe5
ticket: include worker pinning in manual prune 2026-07-11 01:31:22 +09:00
1564d7e411
ticket: route worker workdir registry coder 2026-07-11 01:13:26 +09:00
452d07273c
ticket: manual worker workdir prune 2026-07-11 01:12:43 +09:00
86f652a72f
ticket: accept worker workdir registry work 2026-07-11 01:12:18 +09:00
651422e0c8
ticket: queue 00001KX6BPY7M 2026-07-11 01:10:57 +09:00
fe66437589
ticket: ready backend worker workdir registry 2026-07-11 01:06:19 +09:00
04cb2f700d
ticket: backend worker workdir registry 2026-07-11 00:54:35 +09:00
24156947ac
ui: add runtime workdir pages 2026-07-11 00:15:49 +09:00
47e03bd54b
ui: move workers list to page 2026-07-10 12:06:10 +09:00
a3cb59af43
ui: move worker launch to page 2026-07-10 10:32:44 +09:00
d149f7e936
runtime: namespace spawned worker names 2026-07-10 04:18:14 +09:00
611f1656a3
profile: inherit default model for roles 2026-07-10 03:58:45 +09:00
69fd11a233
runtime: use workspace id for profile archives 2026-07-10 03:14:15 +09:00
9f6abb675c
runtime: tolerate corrupt worker snapshots 2026-07-10 02:51:55 +09:00
c3afcc7491
runtime: fetch profile archives for remote workers 2026-07-10 01:32:26 +09:00
ef0799c278
merge: orchestration 2026-07-09 19:24:12 +09:00
d216b10100
runtime: default working directories to data dir 2026-07-09 19:24:04 +09:00
fa233ba025
runtime: own working directories 2026-07-09 19:04:37 +09:00
36454e831b
ticket: close profile source tree work 2026-07-09 18:18:57 +09:00
9a2e57fb0e
merge: profile source tree editor
# Conflicts:
#	.yoi/tickets/00001KX1JNJ2Y/item.md
#	.yoi/tickets/00001KX1JNJ2Y/thread.md
2026-07-09 18:16:49 +09:00
dccfe539ef
ticket: approve profile source tree work 2026-07-09 18:16:38 +09:00
ae227c7e4d
ticket: record profile source tree symlink fix 2026-07-09 18:10:08 +09:00
13c6548b46
fix: reject profile source root symlinks 2026-07-09 18:08:31 +09:00
2dfad3500f
ticket: record profile source tree symlink blocker 2026-07-09 17:57:33 +09:00
c5a9f8a0ab
ticket: record profile source tree review fixes 2026-07-09 17:52:24 +09:00
941b91261b
fix: complete profile source tree review fixes 2026-07-09 17:47:19 +09:00
0458af721f
ticket: record profile source tree review blockers 2026-07-09 17:32:43 +09:00
129079cad4
ticket: record profile source tree implementation evidence 2026-07-09 17:28:23 +09:00
81abfa630a
feat: add profile source tree editor 2026-07-09 17:22:57 +09:00
ce2ccce98e
ticket: route profile source tree coder 2026-07-09 16:59:00 +09:00
7da14f1a8d
ticket: accept profile source tree work 2026-07-09 16:58:08 +09:00
afc43780a7
ticket: queue 00001KX1JNJ2Y 2026-07-09 16:56:07 +09:00
b06e1b3dc2
ticket: ready profile source tree editor 2026-07-09 16:18:33 +09:00
23ab100d20
ticket: include decodal source editor 2026-07-09 04:21:52 +09:00
1400ebd2f1
ticket: profile source tree virtual fs 2026-07-09 04:19:30 +09:00
292ad2c202
ui: split workspace settings routes 2026-07-09 01:23:34 +09:00
157 changed files with 15248 additions and 5415 deletions

View File

@ -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 conditionsrunning 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

View File

@ -1 +1,3 @@
default = "builtin:companion" default = "builtin:companion"
[profile]

View 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"}

View 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"
}
]
}

View 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。

View 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_changessource-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`: pass94 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass47 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass19 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 ではない。

View 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`: pass91 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass47 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 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`: pass93 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass47 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass19 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`: pass94 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass47 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass19 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`: pass94 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass47 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass19 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_changessource-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`: pass94 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass47 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass19 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 ではない。
---

View 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"}

View 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 statuspresent/removed/missing/unknown 相当)
- cleanlinessclean/dirty/unknown 相当)
- created_at / updated_at
- Backend SQLite に Worker-Workdir link table を追加する。
- worker_id
- workdir_id
- roleprimary など将来拡張可能)
- 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 に保存することはしない。

View 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。

View 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`: pass95 tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass48 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass23 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 pathsruntime 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。
---

View 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"}

View 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 deleteWorker 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 に保存することはしない。

View 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`

View 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`
---

View 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"}

View 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"
}
]
}

View 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` が通る。

View 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`

View 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`
---

View 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"}

View 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"
}
]
}

View 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` が通る。

View 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`

View 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 Tickets 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 Tickets 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`
---

View 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"}

View 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"
}
]
}

View 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` が通る。

View 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`

View 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 featuresTicket / 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`
---

View 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` が通る。

View 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.

View 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
---

View 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` が通る。

View 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.

View 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.
---

View 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` が通る。

View 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.

View 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.
---

View 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` が通る。

View 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.

View 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.
---

View 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` が通る。

View 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.

View 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
View File

@ -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",
] ]

View File

@ -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 { .. }));
}
} }

View File

@ -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);

View File

@ -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 }

View File

@ -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

View File

@ -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();

View File

@ -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"
"#, "#,
); );

View File

@ -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};

View File

@ -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(&registry_path)?; let mut document = read_registry_document(&registry_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");
} }
} }

View File

@ -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"
"#, "#,
) )

View File

@ -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

View File

@ -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,
} }

View File

@ -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,

View File

@ -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,
} }
} }

View File

@ -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,

View File

@ -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,
} }

View File

@ -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"
} }

View File

@ -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,
} }
} }

View File

@ -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)]

View File

@ -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 { .. })
));
}
} }

View File

@ -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);
} }
} }

View File

@ -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();

View File

@ -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,
} }
} }

View File

@ -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(),

View File

@ -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),
); );

View File

@ -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"));
} }

View File

@ -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,
} }

View File

@ -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,
};

View File

@ -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(),

View File

@ -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();

View File

@ -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

View File

@ -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();

View File

@ -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()
} }

View File

@ -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)

View File

@ -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();

View File

@ -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 })?;

View File

@ -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(), .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(),
}
}
} }
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

View File

@ -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

View File

@ -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