Compare commits

..

102 Commits

Author SHA1 Message Date
981f0501a3
merge: backend resource and profile settings 2026-07-08 23:11:54 +09:00
f3cd632b64
ticket: backend memory observation pipeline 2026-07-08 22:46:50 +09:00
5c862ca12a
ticket: close workspace profile settings work 2026-07-08 22:20:36 +09:00
0bf3638c64
merge: workspace profile settings
# Conflicts:
#	.yoi/tickets/00001KX0DSMPT/item.md
#	.yoi/tickets/00001KX0DSMPT/thread.md
2026-07-08 22:19:14 +09:00
137234fae0
ticket: approve profile settings implementation 2026-07-08 22:19:04 +09:00
bfba61cee6
ticket: record profile settings review fixes 2026-07-08 22:07:45 +09:00
0c2ca1eafb
fix: harden profile settings validation 2026-07-08 22:06:12 +09:00
1a8824613b
ticket: record profile settings review blockers 2026-07-08 21:49:19 +09:00
e8048e6dc3
ticket: record profile settings implementation evidence 2026-07-08 21:40:15 +09:00
3eefd33334
feat: add workspace profile settings 2026-07-08 21:38:23 +09:00
950427a135
ticket: backend ticket objective api 2026-07-08 21:28:11 +09:00
3abaf51a66
ticket: record profile settings coder recovery 2026-07-08 21:03:33 +09:00
620e37da8b
ticket: route workspace profile settings coder 2026-07-08 20:58:26 +09:00
c903beee69
ticket: accept workspace profile settings work 2026-07-08 20:56:51 +09:00
a645ee5b79
ticket: close backend resource fetch work 2026-07-08 20:55:03 +09:00
01f94b7591
merge: backend resource fetch api 2026-07-08 20:53:12 +09:00
fd1ab4bad8
ticket: approve backend resource fetch work 2026-07-08 20:53:12 +09:00
237c75448c
ticket: record backend resource fetch review fixes 2026-07-08 20:45:52 +09:00
e716ae44f0
fix: harden backend resource handles 2026-07-08 20:44:04 +09:00
a0ef9a9f82
ticket: record backend resource fetch review blockers 2026-07-08 20:27:33 +09:00
595da68751
ticket: record backend resource fetch implementation evidence 2026-07-08 20:20:32 +09:00
57e96d3be8
feat: add backend resource fetch api 2026-07-08 20:18:38 +09:00
352be1f4c7
ticket: hold profile settings for resource fetch 2026-07-08 19:45:57 +09:00
98f6bd14b3
ticket: queue 00001KX0DSMPT 2026-07-08 19:44:13 +09:00
813af7b4c4
merge: sync orchestration before queue 00001KX0DSMPT 2026-07-08 19:44:12 +09:00
92adee2ada
ticket: route backend resource fetch coder 2026-07-08 19:40:33 +09:00
0c8b5b962c
ticket: ready workspace profile settings 2026-07-08 19:40:06 +09:00
2109ad1021
ticket: use plain text profile editor 2026-07-08 19:39:46 +09:00
1a2097b10d
ticket: accept backend resource fetch work 2026-07-08 19:39:25 +09:00
ffc16deac2
ticket: close decodal profile archive work 2026-07-08 19:35:46 +09:00
0334c5725e
merge: decodal profile archives
# Conflicts:
#	.yoi/tickets/00001KWZ5KERY/item.md
#	.yoi/tickets/00001KWZ5KERY/thread.md
2026-07-08 19:33:39 +09:00
e17c7a5b82
ticket: approve decodal profile archive work 2026-07-08 19:33:27 +09:00
7f81c6cdf4
ticket: record decodal profile archive review fixes 2026-07-08 19:23:18 +09:00
a2833dadca
fix: reject unknown profile archive selectors 2026-07-08 19:21:52 +09:00
27fa1cc815
ticket: record decodal profile archive review blockers 2026-07-08 19:07:16 +09:00
ab55238d29
ticket: hold backend resource fetch for decodal ordering 2026-07-08 19:05:49 +09:00
81cf73e452
ticket: queue 00001KX0G06VA 2026-07-08 19:04:10 +09:00
0dc5fc687f
merge: sync orchestration before queue 00001KX0G06VA 2026-07-08 19:04:10 +09:00
89fdc6b443
ticket: record decodal profile archive implementation evidence 2026-07-08 18:59:39 +09:00
a823d414d0
feat: add decodal profile archives 2026-07-08 18:57:54 +09:00
048b0af614
ticket: avoid changing active decodal work 2026-07-08 18:44:12 +09:00
461ae6a89e
ticket: ready runtime backend resource rest api 2026-07-08 18:28:04 +09:00
dccd8d03af
ticket: route decodal profile archive coder 2026-07-08 18:14:34 +09:00
63f3129eb7
ticket: add runtime backend resource channel 2026-07-08 18:14:31 +09:00
56cac3c74a
ticket: accept decodal profile archive work 2026-07-08 18:13:24 +09:00
190f6a2a8f
ticket: queue 00001KWZ5KERY 2026-07-08 18:11:22 +09:00
e5fb26ca74
ticket: ready decodal profile archive migration 2026-07-08 18:03:55 +09:00
1688f2a640
fix: separate profile base from working directory 2026-07-08 18:03:55 +09:00
061e52c35c
ticket: profile source archive migration order 2026-07-08 18:01:34 +09:00
bbe6694122
ticket: workspace profile settings api 2026-07-08 17:35:01 +09:00
a84e968699
ticket: use event driven worker config bundle sync 2026-07-08 15:55:12 +09:00
fb25f1a33d
ticket: scope config bundle sync implementation 2026-07-08 06:05:47 +09:00
ebc3804ebd
ticket: design config bundle sync strategy 2026-07-08 05:52:40 +09:00
25900f31cf
refactor: remove working directory allocation terminology 2026-07-08 04:56:22 +09:00
5c33b2f63d
ticket: close runtime connection reload 2026-07-08 04:16:57 +09:00
fa219e40b3
merge: browser working directory spawn 2026-07-08 02:27:53 +09:00
9745bf041c
ui: show worker spawn diagnostics 2026-07-08 02:27:19 +09:00
a6fe68301c
Merge branch 'develop' into orchestration
# Conflicts:
#	.yoi/objectives/00001KVJPT2PP/item.md
#	.yoi/objectives/00001KWW44EXK/item.md
2026-07-08 01:15:07 +09:00
40463b6133
refactor: rename execution workspace to working directory 2026-07-08 01:14:14 +09:00
0cad2308cc
ticket: close browser execution workspace work 2026-07-07 23:59:03 +09:00
b52986e55a
merge: browser execution workspaces 2026-07-07 23:57:00 +09:00
7acf06ad23
ticket: approve browser execution workspace work 2026-07-07 23:57:00 +09:00
55105564bf
ticket: record browser execution workspace review fixes 2026-07-07 23:51:40 +09:00
9adb0fae82
fix: preserve execution workspace cwd diagnostics 2026-07-07 23:50:01 +09:00
a6b93ceabc
objective: clarify working directory terminology 2026-07-07 23:41:27 +09:00
05a592d80b
ticket: record browser execution workspace review blocker 2026-07-07 23:37:08 +09:00
41cc66b11b
ticket: record browser execution workspace implementation evidence 2026-07-07 23:23:35 +09:00
684b19e87c
feat: add browser execution workspaces 2026-07-07 23:21:20 +09:00
57a5af8efd
ticket: route browser execution workspace coder 2026-07-07 22:42:57 +09:00
ecf10c72ab
ticket: accept browser execution workspace work 2026-07-07 22:41:51 +09:00
9f1c17d4e9
ticket: queue 00001KWY8EHEJ 2026-07-07 22:40:23 +09:00
6db5751bbf
ticket: ready browser execution workspace spawn 2026-07-07 21:26:40 +09:00
ce773d0fb4
ticket: browser execution workspace spawn 2026-07-07 21:22:42 +09:00
fbc9ba411e
merge: workspace id route scoping
# Conflicts:
#	web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte
2026-07-07 06:27:48 +09:00
7f6a057e22
ui: refine workspace sidebar controls 2026-07-07 06:19:08 +09:00
1d88b333a9
ticket: close workspace id route scoping 2026-07-07 05:53:50 +09:00
13e756e74f
merge: workspace id route scoping 2026-07-07 05:52:46 +09:00
f3fdc98319
ticket: approve workspace id route scoping 2026-07-07 05:52:46 +09:00
3315fa5458
ticket: record workspace id route review fixes 2026-07-07 05:45:40 +09:00
71340b8999
fix: redirect unscoped workspace routes 2026-07-07 05:44:22 +09:00
a2f2e37585
ticket: record workspace id route review blockers 2026-07-07 05:36:54 +09:00
55f93fd987
ticket: record workspace id route implementation evidence 2026-07-07 05:27:20 +09:00
f6ad9cfcd3
feat: scope workspace routes by id 2026-07-07 05:26:03 +09:00
ab33c30d7c
ticket: route workspace id scoping coder 2026-07-07 04:55:32 +09:00
34836d97ae
ticket: accept workspace id route scoping 2026-07-07 04:54:37 +09:00
384d162b74
ticket: queue 00001KWWE8E04 2026-07-07 04:53:14 +09:00
76153b64c7
merge: sync orchestration before queue 00001KWWE8E04 2026-07-07 04:53:14 +09:00
72ace6e913
ticket: ready workspace scoped routes 2026-07-07 04:48:42 +09:00
376a43b8ae
ticket: close execution workspace materializer 2026-07-07 04:24:31 +09:00
c4cdf1c37e
merge: execution workspace materializer 2026-07-07 04:23:08 +09:00
ab19882b8e
ticket: approve execution workspace materializer 2026-07-07 04:23:08 +09:00
673fbeed09
ticket: record execution workspace review fixes 2026-07-07 04:17:23 +09:00
c90ebf2468
fix: keep execution workspace paths internal 2026-07-07 04:16:04 +09:00
6c4df18da1
ticket: record execution workspace review blocker 2026-07-07 04:06:30 +09:00
5ef8e4e8ba
refactor: move workspace pages into routes 2026-07-07 04:04:37 +09:00
504090ac99
ticket: record execution workspace implementation evidence 2026-07-07 03:57:46 +09:00
8b7a5da031
feat: materialize execution workspaces 2026-07-07 03:56:32 +09:00
9a064117bd
ticket: route execution workspace coder 2026-07-07 03:31:17 +09:00
50c80e7747
ticket: accept execution workspace materializer 2026-07-07 03:30:04 +09:00
4e72bc6be3
ticket: queue 00001KWW9DYH4 2026-07-07 03:28:26 +09:00
3cee4116d1
ticket: plan execution workspace materializer 2026-07-07 03:27:12 +09:00
1f69061956
fix: normalize runtime workspace bootstrap 2026-07-07 03:27:12 +09:00
121 changed files with 12891 additions and 1358 deletions

View File

@ -2,7 +2,7 @@
title: "Team workspace control plane and runtime architecture"
state: "active"
created_at: "2026-06-20T14:26:29Z"
updated_at: "2026-07-03T18:05:00Z"
updated_at: "2026-07-07T12:40:00Z"
linked_tickets: ["00001KVMFFYVX", "00001KWMBAA6V"]
---
@ -20,14 +20,14 @@ Yoi を、単一のローカル開発ディレクトリで動くエージェン
- Workspace: チームまたはプロジェクトの管理単位。Ticket、Objective、Memory、Knowledge、Artifact、Policy、Actor、Repository、Runtime state を持つ。Git Repository root ではない。
- Control plane: Workspace の正本を持ち、Web UI / API / CLI から操作される管理システム。
- Runtime: Worker 群を束ねる実行基盤。Worker lifecycle、sandbox、mount、cache、checkout/worktree/container filesystem などの Execution Workspace materialization、event/control plane を管理する。将来的には 1 つの Runtime が複数 Workspace / Repository の Worker を抱えられる。
- Worker: Runtime が管理する 1 つの agent/session/process。Runtime が用意した Execution Workspace と authority の中で動く。
- Runtime: Worker 群を束ねる実行基盤。Worker lifecycle、sandbox、mount、cache、checkout/worktree/container filesystem などの working directory materialization、event/control plane を管理する。将来的には 1 つの Runtime が複数 Workspace / Repository の Worker を抱えられる。
- Worker: Runtime が管理する 1 つの agent/session/process。Runtime が用意した working directory と authority の中で動く。
- Repository: Workspace に接続される source/storage。コード、ドキュメント、local directory、object storage、artifact store、dataset などを含む。Git Repository も Repository の一種であり、基本的には filesystem path ではなく URI / URL で識別する。
- RepositoryId: Workspace 内でどの Repository を対象にするかを指す安定 identifier。Git hash、branch、path ではない。
- Repository provider: Repository の種類ごとの実装。Git、local filesystem、object store、artifact store、将来の non-Git VCS など。
- RepositorySelector: Repository provider に渡す未解決の地点指定。branch/tag/PR/revspec/bookmark/revset/path@revision/object version/latest など provider-specific な symbolic / mutable / query-like locator であり、それ自体は再現性の authority ではない。
- RepositoryPoint: RepositorySelector をある時点で解決した具体地点。Git commit/tree、Mercurial changeset、SVN revision、object store version/manifest digest、file snapshot など provider ごとの immutable / reproducible point を表し、Artifact/evidence に残す。
- Execution Workspace: Runtime が Worker のために作る作業環境。1 つ以上の RepositoryPoint から materialize される作業用ディレクトリ、container filesystem、sandbox mount の集合であり、Git worktree、clone、sparse checkout などはこれを作る手段である。
- working directory: Runtime が Worker のために作る作業環境。1 つ以上の RepositoryPoint から materialize される作業用ディレクトリ、container filesystem、sandbox mount の集合であり、Git worktree、clone、sparse checkout などはこれを作る手段である。Browser-facing UI/API では product `Workspace` と混同しないよう、この呼称に寄せる。`Volume` は storage backing の候補名に留め、作業領域そのものの呼称にはしない。
- Ticket: チームで扱う作業単位。目的、要件、判断、議論、完了条件、関係、証跡を持つ。
- Objective: 複数の Ticket を束ねる長期目標や設計方針。
- Artifact: Ticket や Worker 実行に紐づく成果物や証跡。diff、log、validation result、review result、report など。
@ -46,7 +46,7 @@ Yoi を、単一のローカル開発ディレクトリで動くエージェン
- 管理システムと Runtime を分ける。
- まず Web から Ticket、Objective、Memory、Knowledge、Artifact、Runtime / Worker state を見られるようにする。
- 最初はローカル Runtime を使い、後でリモート Runtime、クラウド Runtime、runtime pool、resource allocation、quota、billing、sandboxing に拡張する。
- Git ホスティング機能を取り込むのではなく、Git Repository / worktree / clone は Repository provider と Execution Workspace materialization の手段として扱う。
- Git ホスティング機能を取り込むのではなく、Git Repository / worktree / clone は Repository provider と working directory materialization の手段として扱う。
OSS として Control plane、Runtime、Web frontend、protocol を公開しつつ、managed service では hosted control plane、runtime fleet、リソース柔軟性、team auth、backup、audit、availability、multi-tenant operations で価値を出す。
@ -68,7 +68,7 @@ Ticket と Objective は Repository 配下に置かず、Workspace 配下に平
RepositorySelector は Git branch/tag の抽象化ではない。Selector は provider-specific な未解決 locator であり、Git provider なら branch/tag/ref/revspec/PR/commit、Mercurial provider なら bookmark/revset/changeset、SVN provider なら path/revision、object store provider なら prefix/version/latest などを解釈する。実行時には Control plane または Repository provider が Selector を RepositoryPoint に解決し、Runtime はその RepositoryPoint を materialize する。
Worker launch request は Ticket の target selector を concrete RepositoryPoint に解決し、その RepositoryPoint から Runtime が Execution Workspace を materialize する。Git worktree 相当の機能は、この Execution Workspace を作るための実装戦略として扱う。
Worker launch request は Ticket の target selector を concrete RepositoryPoint に解決し、その RepositoryPoint から Runtime が working directory を materialize する。Git worktree 相当の機能は、この working directory を作るための実装戦略として扱う。
Backend は cwd や `--workspace` を暗黙の Repository として扱わない。`--workspace` は当面 workspace config root / local descriptor root を指すだけであり、Repository registry は明示設定された Workspace config から構築する。短期的には `.yoi/workspace-backend.local.toml``[[repositories]]` を local descriptor として使い、`uri = "."` のような local repository も明示 entry として登録する。`./` を暗黙 Repository として自動採用しない。
@ -86,7 +86,7 @@ Ticket target は intent/selector であり、実行再現性のための immuta
Ticket
-> target selectors: Repository + ref selector + path + intent
-> resolved RepositoryPoint
-> Execution Workspace
-> working directory
-> WorkerRef / Artifact / Evidence
-> Review / Decision
-> Audit / Notification
@ -119,7 +119,7 @@ Ticket には次の概念が必要になる。
- Actor identity: human / agent / system / service account.
- Assignment / owner / reviewer / watcher.
- Typed thread events: comment, decision, plan, review, implementation report, state transition.
- Linked Objective / Artifact / WorkerRef / Repository / RepositoryPoint / Execution Workspace.
- Linked Objective / Artifact / WorkerRef / Repository / RepositoryPoint / working directory.
- Permission / visibility.
- Audit trail.
- Notification / mention.
@ -170,17 +170,17 @@ Ticket / user intent
-> RepositoryId + RepositorySelector + path scope + required authority
-> resolved RepositoryPoint
-> WorkerLaunchRequest / ConfigBundle / AuthorityBundle
-> Runtime ExecutionWorkspaceMaterializer
-> Execution Workspace allocation
-> Runtime WorkingDirectoryMaterializer
-> working directory allocation
-> Worker process
-> WorkerRef + Artifact/evidence
```
Control plane は Workspace authority、Ticket target、Repository registry、Actor permission、設定 bundle の正本を持つ。Control plane または Repository provider は RepositorySelector を RepositoryPoint に解決し、どの地点を対象にしたかを evidence に残す。Runtime は RepositoryPoint、materialization policy、sandbox policy、mount/cache/secret policy、Worker config を受け取り、Runtime-local な Execution Workspace を確保して Worker を起動する。
Control plane は Workspace authority、Ticket target、Repository registry、Actor permission、設定 bundle の正本を持つ。Control plane または Repository provider は RepositorySelector を RepositoryPoint に解決し、どの地点を対象にしたかを evidence に残す。Runtime は RepositoryPoint、materialization policy、sandbox policy、mount/cache/secret policy、Worker config を受け取り、Runtime-local な working directory を確保して Worker を起動する。
この境界では、Worker は host filesystem path や Repository credential を自分で発見しない。Worker は Runtime が materialize した workspace root、mount、環境変数、tool authority、config bundle だけを見る。Runtime は Execution Workspace の lifecycle、cleanup、cache reuse、namespace、quota、sandbox boundary、event collection を管理する。Control plane / Browser-facing API は raw host path、secret、socket、internal runtime path を authority-bearing internals として扱い、必要な evidence だけを Artifact として公開する。
この境界では、Worker は host filesystem path や Repository credential を自分で発見しない。Worker は Runtime が materialize した working directory root、mount、環境変数、tool authority、config bundle だけを見る。Runtime は working directory の lifecycle、cleanup、cache reuse、namespace、quota、sandbox boundary、event collection を管理する。Control plane / Browser-facing API は raw host path、secret、socket、internal runtime path を authority-bearing internals として扱い、必要な evidence だけを Artifact として公開する。
v0 materializer は existing local root を明示的な Execution Workspace として返してよい。ただし型と呼び出し順序は、後で Git worktree、clone、sparse checkout、container filesystem、remote object snapshot、multi-repository mount に置き換えられる形にする。Runtime process 起動時の `--workspace` はこの v0 materializer の legacy bootstrap input であり、Runtime identity や long-term workspace binding ではない。
v0 materializer は existing local root を明示的な working directory として返してよい。ただし型と呼び出し順序は、後で Git worktree、clone、sparse checkout、container filesystem、remote object snapshot、multi-repository mount に置き換えられる形にする。Runtime process 起動時の `--workspace` はこの v0 materializer の legacy bootstrap input であり、Runtime identity や long-term workspace binding ではない。
その後で、remote Runtime、self-hosted Runtime、hosted cloud runtime fleet、runtime pool、resource allocation、quota、billing、sandbox、network policy、secret distribution を追加する。
@ -204,7 +204,7 @@ Web UI は Ticket、Objective、Memory、Knowledge、Runtime、Worker、Artifact
### 7. 多重起動コストと runtime placement を見直す
Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical Worker session と Runtime process/resource placement を分ける。Runtime は Git Repository root や Workspace path に固定されず、request ごとに必要な Execution Workspace を materialize できる実行基盤として扱う。
Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical Worker session と Runtime process/resource placement を分ける。Runtime は Git Repository root や Workspace path に固定されず、request ごとに必要な working directory を materialize できる実行基盤として扱う。
初期 Workspace DB では、Worker を canonical table として永続化しない。Runtime / Worker 一覧は backend-local runtime inspection や将来の Runtime protocol から逐次取得する live view とし、Ticket に関わった Worker は Ticket thread events と WorkerRef snapshot / TicketWorkerLink として記録する。
@ -214,7 +214,7 @@ Worker の一元管理、データ永続化、アーカイブは将来的には
- Worker identity と Runtime process/resource placement の分離。
- 1 Runtime が複数 Workspace / Repository の Worker を抱える場合の namespace、quota、cleanup、audit boundary。
- Runtime-side Execution Workspace materialization、sandbox、mount、checkout/worktree/container filesystem の責務。
- Runtime-side working directory materialization、sandbox、mount、checkout/worktree/container filesystem の責務。
- Provider client、tool registry、resource cache の共有可能性。
- Prompt/resource/profile/config bundle resolution cache。
- Model call multiplexing and scheduling。
@ -226,7 +226,7 @@ Worker の一元管理、データ永続化、アーカイブは将来的には
## Initial phases / candidate tickets
1. **Vocabulary / architecture record**
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / Execution Workspace / Runtime / Worker / Control Plane / Ticket / Memory / Knowledge の用語と境界を固める。
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / working directory / Runtime / Worker / Control Plane / Ticket / Memory / Knowledge の用語と境界を固める。
2. **Team-space canonical data model**
- Ticket / Objective / Target / Artifact / Actor / Permission / Audit / Memory / Knowledge の entity/event model を設計する。
3. **Ticket evidence model**
@ -238,8 +238,8 @@ Worker の一元管理、データ永続化、アーカイブは将来的には
6. **Web control plane MVP design**
- read-only Ticket / Objective / Memory / Knowledge / Runtime / Worker state UI/API の範囲を決める。
7. **Local Runtime protocol design**
- Web/control plane から local Runtime に安全な操作を送り、Runtime が Worker lifecycle と Execution Workspace materialization を担う protocol と authority boundary を設計する。
8. **Repository and Execution Workspace materialization model**
- Web/control plane から local Runtime に安全な操作を送り、Runtime が Worker lifecycle と working directory materialization を担う protocol と authority boundary を設計する。
8. **Repository and working directory materialization model**
- Repository URI、Repository provider capability、RepositorySelector resolution、RepositoryPoint evidence、Git worktree / clone / sparse checkout / future source backend を Runtime-side materialization strategy として抽象化する。
9. **Remote/hosted runtime foundation**
- runtime registration, heartbeat, capability advertisement, job assignment, logs/events, secrets, sandbox/resource policy を設計する。
@ -258,7 +258,7 @@ Worker の一元管理、データ永続化、アーカイブは将来的には
## Success criteria / exit conditions
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / Execution Workspace / Runtime / Worker / Control Plane / Ticket / Memory / Knowledge の境界が文書化されている。
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / working directory / Runtime / Worker / Control Plane / Ticket / Memory / Knowledge の境界が文書化されている。
- Ticket が team coordination record として、target selector / Artifact / Actor / Permission / Audit と分離された model を持つ。
- `.yoi` local backend は compatibility/local backend として整理され、server-side canonical backend の設計を阻害しない。
- Web UI/API が Ticket / Objective / Runtime / Worker state を中心とした read-only view を提供できる設計または MVP を持つ。Memory / Knowledge は既存 record の表示または将来 placeholder に留め、本格再設計をこの段階の必須条件にしない。
@ -266,7 +266,7 @@ Worker の一元管理、データ永続化、アーカイブは将来的には
- Runtime は single Workspace / Git repository root 専用 process ではなく、sandbox/authority が成立すれば複数 Workspace / Repository の Worker を抱えられる execution substrate として設計されている。
- Git Repository root に依存しない Workspace model があり、Git Repository は Repository provider の一種として扱われている。
- Ticket と Objective は Workspace 配下に平たく存在し、Repository への所属ではなく RepositoryId / RepositorySelector / path scope / intent で対象を表現する。
- Git worktree 相当は Execution Workspace materialization strategy として扱われ、Artifact/evidence が concrete RepositoryPoint を記録する。
- Git worktree 相当は working directory materialization strategy として扱われ、Artifact/evidence が concrete RepositoryPoint を記録する。
- Memory / Knowledge は Ticket / Artifact の authority を置き換えない record として platform contract だけを持つ。本格的な意味論・抽出・承認・検索・staleness 処理は、Memory の保存先を Workspace backend / control plane record に移すタイミングで回収する。
- Hosted Runtime / resource allocation / SaaS offering に進むための後続 Ticket が切れる状態になっている。
- 既存 local dogfooding runtime を壊さず、local use と remote-capable architecture が両立している。

View File

@ -0,0 +1,324 @@
---
title: "Runtime working directory materialization and sandboxed agent environments"
state: "active"
created_at: "2026-07-06T16:28:12Z"
updated_at: "2026-07-07T12:40:00Z"
linked_tickets: ["00001KWPC13WQ", "00001KWMBAA6V"]
---
## Goal
Runtime が Worker ごとに安全で安価な作業環境を用意できるようにする。Yoi の Runtime は、単に既存ディレクトリで Worker process を起動する launcher ではなく、RepositoryPoint から working directory を materialize し、sandbox / mount / cache / cleanup / evidence を管理する実行基盤になる。
この Objective の中心は、Worker 用 working directory の materialization、Repository cache、working directory allocation、sandboxed agent environment の境界を設計し、将来的に 1 つの Runtime が複数 Workspace / Repository の Worker を抱えられるようにすることである。
初期実装では Git/local repository を主対象にしてよい。ただし設計は Git worktree 固定にしない。Git worktree、bare object cache、sparse checkout、copy-on-write snapshot、reflink copy、APFS clonefile、btrfs snapshot、overlay filesystem、container filesystem、remote object snapshot は、すべて working directory materialization strategy の候補として扱う。
## Motivation / background
現在の Runtime は `--workspace` で与えられた単一 root を Worker の workspace scope として使う。この形では次の問題がある。
- 複数 Worker が同じ repository root を scope として要求すると allocation conflict が起きる。
- Runtime が single Workspace / Git repository root 専用 process になってしまう。
- Worker が source repository root を直接触るため、sandbox / cleanup / quota / evidence の境界が曖昧になる。
- Worker ごとに full clone すると容量と時間のコストが大きすぎる。
- `.yoi` / Backend fs-store / Workspace descriptor と、Worker 実行用 checkout / scratch / build output の lifecycle が混ざりやすい。
Yoi は Workspace / Repository / Runtime を分ける方針になっている。Backend は Repository registry、RepositorySelector、RepositoryPoint、Ticket/Artifact evidence の authority を持つ。一方 Runtime は RepositoryPoint を受け取り、Worker が使う working directory を用意する責務を持つべきである。
したがって、Repository を持ってくる実体ディレクトリは Backend / Workspace store ではなく Runtime 管理領域に置く。`./.yoi` は local descriptor / compatibility / project record surface であり、Worker ごとの checkout、worktree、snapshot、sandbox root、build cache、dependency cache を置く場所ではない。
参考になる方向性として、Rift のような copy-on-write workspace snapshot / reflink / btrfs snapshot / APFS clonefile による安価な workspace creation がある。ただし Yoi は Rift を Git worktree 代替 CLI として直接前提にするのではなく、Runtime materializer backend の一候補として扱う。
## Glossary
- 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 から共有される。
- working directory: Worker ごとの作業環境。短寿命で、Worker が読み書きする root / mounts / scratch / overlay を含む。Browser-facing UI/API では `Workspace` と混同しないよう、この呼称に寄せる。`Volume` は storage backing の候補名であり、この作業領域そのものの呼称にはしない。
- 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 を持つ。
- Sandbox policy: Worker が見られる filesystem、network、process、secret、tool authority の境界を表す policy。
- Dirty state policy: local uncommitted changes を Worker environment に含めるかどうかの方針。clean point only、patch artifact apply、snapshot current worktree など。
## Strategy / design direction
### 1. Backend store と Runtime execution storage を分ける
Workspace/backend store は canonical or local descriptor records を扱う。
```text
Backend / Control plane store
- Workspace registry
- Repository registry
- Ticket / Objective
- Artifact metadata / evidence
- Actor / Permission / Audit
- Runtime registry / observed state
```
Runtime execution storage は Worker 実行のための materialized filesystem と cache を扱う。
```text
Runtime root
- runtime catalog / runtime-local DB
- config bundles
- worker metadata / transcript references
- repository-cache
- working-directories
- sandbox state
- build/dependency cache
- cleanup ledger
```
この 2 つを混ぜない。特に `.yoi` や workspace config root に Worker ごとの checkout/worktree/sandbox root を置かない。
推奨配置の方向:
```text
~/.yoi/
workspace-server/
backend.db
workspaces/
<workspace-id>/
workspace.db
artifacts/
runtimes/
<runtime-id>/
runtime.db
config-bundles/
workers/
<worker-id>/
metadata/
repository-cache/
git/
<repository-cache-key>/
bare.git
working-directories/
<allocation-id>/
root/
mounts/
scratch/
materialization.json
build-cache/
tmp/
```
### 2. clone ではなく materialize と呼ぶ
Runtime は Repository を毎回 clone するのではない。Runtime は RepositoryPoint を Worker 用の working directory として materialize する。
```text
RepositoryId + RepositorySelector
-> resolved RepositoryPoint
-> Runtime repository-cache
-> WorkingDirectoryMaterializer
-> WorkingDirectoryAllocation
-> Worker process
```
Git の場合でも materialization strategy は複数あり得る。
- shared bare cache + detached Git worktree
- sparse checkout
- partial clone / blob filter
- reflink copy
- btrfs writable snapshot
- APFS clonefile
- overlay filesystem
- external tool backed snapshot, e.g. Rift-like CoW workspace creation
### 3. Repository cache と Worker working directory を分離する
full clone を Worker ごとに作らない。重い source/object data は Runtime-local repository cache に集約し、Worker ごとの working directory は cheap allocation にする。
Git v0 の方向:
```text
repository-cache/git/<repo-key>/bare.git
working-directories/<allocation-id>/root/<repository-id>/
```
初回:
```text
git clone --mirror <uri> repository-cache/git/<repo-key>/bare.git
```
次回以降:
```text
git -C repository-cache/git/<repo-key>/bare.git fetch --prune
```
Worker allocation:
```text
git --git-dir=<bare.git> worktree add --detach <execution-root>/<repository-id> <commit>
```
Git branch 名を直接 checkout しない。Git worktree は同一 branch を複数 worktree に checkout しづらいため、RepositorySelector を RepositoryPoint に解決し、resolved commit を detached worktree として materialize する。Worker が branch を必要とする場合は Worker/Task ごとの synthetic branch を別途作る。
### 4. Dirty state を明示 policy にする
local dirty changes を暗黙に Worker に見せない。
可能な policy:
- `clean_point_only`: dirty workspace は materialization 拒否。再現性が高く v0 の default 候補。
- `patch_artifact`: clean RepositoryPoint を materialize し、Backend が保存した dirty diff artifact を apply する。
- `current_worktree_snapshot`: CoW/reflink/Rift-like snapshot で現在の working tree を snapshot として materialize する。便利だが evidence と cleanup の扱いを明示する必要がある。
- `direct_legacy_mount`: 既存 root をそのまま渡す。debug/legacy only。通常 Worker creation の default にしない。
Dirty state を含める場合、Artifact/evidence には source RepositoryPoint、patch/snapshot digest、created_at、materializer kind を残す。
### 5. Sandbox を materialization と同じ境界で扱う
working directory は単なる directory path ではなく、Worker が見てよい filesystem view である。Runtime は working directory allocation と同時に sandbox/mount/authority を構築する。
Worker に渡すもの:
- workspace root
- repository mounts
- scratch/cache dirs
- tool authority
- env vars
- config bundle
- secret handles, not raw secrets
Worker が自分で発見してはいけないもの:
- host repository root
- Backend store path
- `.yoi` authority-bearing internals
- raw credentials
- Runtime socket/store/cache internals
- sibling Worker working directories
Sandbox v0 は strong isolation でなくてもよい。ただし型と lifecycle は、後で container sandbox、namespace, mount filtering, network policy, secret boundary に拡張できる形にする。
### 6. working directory registry を持つ
Runtime は materialized workspace を filesystem だけでなく registry でも管理する。
必要な record:
```text
WorkingDirectoryAllocation
id
runtime_id
worker_id
workspace_id
repository_points[]
materializer_kind
root
mounts[]
scratch
source_cache_refs[]
sandbox_policy
dirty_state_policy
cleanup_policy
status: active | stopped | cleanup_pending | removed | failed
created_at
stopped_at
diagnostics[]
```
この registry は Runtime root 側に置く。Backend は必要な evidence と summary だけを受け取る。Browser-facing API は raw host paths を原則漏らさない。
### 7. Heavy regenerable artifacts は policy で除外または cache 化する
Worker working directory creation では、`node_modules`, `target`, `.venv`, framework cache, dist, build, coverage などを無条件に full copy しない。
Materialization policy は以下を持てるようにする。
- exclude regenerable artifacts
- include manifests / lockfiles
- share dependency cache
- use build cache
- path scope / sparse checkout
- per-repository materialization options
Rift の filtered CoW creation のように、重い artifacts を除外しながら source tree を高速に用意できる strategy を将来取り込めるようにする。
## Initial implementation phases
### Phase 1: Materialization boundary and legacy allocation record
- `CreateWorkerRequest` に working directory request / target placeholder を追加する。
- `WorkingDirectoryMaterializer` trait を `worker-runtime` に追加する。
- `WorkerRuntimeExecutionBackend` が Worker spawn 前に materializer を呼ぶ順序にする。
- v0 materializer は existing local root を explicit allocation として返してよいが、`direct_legacy_mount` として明示し、通常設計の final form と混同しない。
- Allocation record / cleanup policy / diagnostics の型を先に作る。
- Worker が source repository root を直接 scope として要求する経路を deprecated/legacy に閉じ込める。
### Phase 2: Runtime root and working directory storage
- `--runtime-root` を導入し、Runtime state / repository-cache / working-directories / worker metadata / worker runtime dirs を Runtime root 配下へ寄せる。
- `--workspace` は legacy bootstrap input としてだけ扱い、Runtime identity / long-term workspace binding から外す。
- Runtime root default は user data 配下にする。
- working directory allocation registry を Runtime root に保存する。
### Phase 3: Git cached detached worktree materializer
- Git repository cache を Runtime root に作る。
- RepositorySelector を RepositoryPoint に解決する呼び出し境界を作る。
- resolved commit/tree を evidence として残す。
- Worker ごとに detached worktree を `working-directories/<allocation-id>/root/<repository-id>` に作る。
- Worker stop / cleanup 時に `git worktree remove` と registry cleanup を行う。
- dirty state は v0 では `clean_point_only` を default にし、dirty local workspace は明示 diagnostic で拒否する。
### Phase 4: Path scope / sparse checkout / cache policies
- Ticket target / Worker launch request の path scope を materialization に渡す。
- Git sparse checkout を materializer strategy として追加する。
- heavy artifact exclude / dependency cache / build cache policy を導入する。
### Phase 5: CoW / snapshot materializer
- btrfs snapshot、Linux reflink、macOS APFS clonefile、Rift-like snapshot backend を materializer strategy として検討・実装する。
- `current_worktree_snapshot` policy を evidence と cleanup 付きで扱う。
- source root と generated workspace の registry / ancestor / cleanup model を設計する。
### Phase 6: Strong sandbox and remote Runtime support
- container filesystem / mount namespace / network policy / secret handle / tool authority を Runtime allocation と統合する。
- Runtime が複数 Workspace / Repository の Worker を同時に抱える場合の namespace、quota、cleanup、audit boundary を固める。
- remote/self-hosted/hosted Runtime fleet で repository cache と working directory storage をどう扱うかを設計する。
## Non-goals
- v0 で完全な container sandbox を実装すること。
- Worker ごとに full clone すること。
- `.yoi` や Backend workspace store に Worker working directory を置くこと。
- Git worktree を唯一の materialization strategy として固定すること。
- Dirty local changes を暗黙に Worker に渡すこと。
- Browser-facing API に raw host path、secret、Runtime internal store path を公開すること。
- Repository credential / secret distribution の本格設計をこの Objective だけで完了させること。
## Success criteria / exit conditions
- Runtime root、Repository cache、working directory、Backend/Workspace store の境界が文書化されている。
- Runtime が Worker spawn 前に working directory materializer を呼ぶ型と順序を持つ。
- Worker は source repository root ではなく materialized working directory を scope として起動する。
- working directory allocation が Runtime registry に記録され、cleanup policy を持つ。
- Git/local repository の v0 materializer が full clone 連発ではなく shared cache / detached worktree / cheap allocation の方向に進んでいる。
- dirty state policy が明示され、clean point、patch artifact、snapshot のどれを使ったか evidence に残せる。
- Runtime process 起動時の `--workspace` は legacy bootstrap input として隔離され、Runtime identity や single workspace binding とみなされない。
- Worker ごとの scope allocation conflict が、同一 source root を直接渡す設計ではなく materialized workspace allocation によって解消される。
- Sandbox / mount / cache / secret boundary を後続実装で強化できる model になっている。
## References
- [Rift: Worktree alternative for fast copy-on-write workspaces](https://github.com/anomalyco/rift) — CoW snapshot / reflink / btrfs snapshot / APFS clonefile による高速 workspace creation、heavy regenerable artifacts の除外、workspace registry などを Runtime materializer backend 設計の参考にする。
## Decision context
- Runtime は Worker 群を束ねる実行基盤であり、Worker 用の作業環境を用意する責務を持つ。
- Repository は clone されるものではなく、RepositoryPoint から Worker 用 working directory として materialize される。
- Runtime execution storage は Backend/Workspace store と分離する。`.yoi` は Worker working directory 置き場ではない。
- full clone を Worker ごとに作らない。Runtime-local repository cache と Worker-local cheap workspace allocation を分ける。
- Git detached worktree は v0 materialization strategy として有力だが、Git worktree 固定の設計にはしない。
- CoW snapshot / reflink / btrfs snapshot / APFS clonefile / Rift-like workspace creation は、将来の materializer backend として検討する。
- Dirty local state は暗黙に渡さず、policy と evidence を持って扱う。
- Sandbox は後付けの別機能ではなく、working directory allocation と同じ境界で扱う。

View File

@ -1,8 +1,8 @@
---
title: 'Live-reload Runtime connection registry changes'
state: 'done'
state: 'closed'
created_at: '2026-07-03T15:59:48Z'
updated_at: '2026-07-03T19:47:23Z'
updated_at: '2026-07-07T13:40:23Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-03T16:52:35Z'

View File

@ -0,0 +1,3 @@
Ticket `00001KWMBAA6V` (`Live-reload Runtime connection registry changes`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -237,4 +237,24 @@ Skipped:
- `nix build .#yoi --no-link` per earlier user instruction to skip heavy build.
---
<!-- event: state_changed author: hare at: 2026-07-07T13:40:23Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-07T13:40:23Z status: closed -->
## 完了
Ticket `00001KWMBAA6V` (`Live-reload Runtime connection registry changes`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
---

View File

@ -43,7 +43,7 @@ queued_at: '2026-07-04T19:34:04Z'
## 非目標
- Runtime materialization / Execution Workspace 作成をこのチケットで実装すること。
- Runtime materialization / working directory 作成をこのチケットで実装すること。
- multi-workspace Backend store を `~/.yoi/` に完全移行すること。
- Repository credential / clone / fetch / write operation を実装すること。
- Ticket target から RepositoryPoint を解決して Worker launch に渡すこと。

View File

@ -85,7 +85,7 @@ Implementation latitude:
- focused tests の構成は backend helper/route-level と web model/component-levelの現実的な範囲で選んでよい。
Escalate if:
- credential/clone/fetch/write operation、Runtime materialization、Execution Workspace 作成、multi-workspace store 移行、Ticket target からの RepositoryPoint 解決が必要になる場合。
- credential/clone/fetch/write operation、Runtime materialization、working directory 作成、multi-workspace store 移行、Ticket target からの RepositoryPoint 解決が必要になる場合。
- Browser-facing API に secret/auth ref/internal absolute paths を出さないと実装できない場合。
- `RepositorySelector` / `RepositoryPoint` の概念境界を設計変更する必要が出た場合。

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260706-182951-1","ticket_id":"00001KWW9DYH4","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWW9DYH4 は独立 queued Ticket で blocker なし。Repository registry の次段として、local Git configured repository から Runtime root 配下に Worker ごとの detached working directory を materialize する v0 を実装する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWW9DYH4-execution-workspace-materializer","worktree":"/home/hare/Projects/yoi/.worktree/00001KWW9DYH4-execution-workspace-materializer","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、worker-runtime の working directory materialization boundary / local Git detached worktree allocation / cleanup evidence / tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / Objective boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-06T18:29:51Z"}

View File

@ -0,0 +1,52 @@
---
title: 'Add local Git worktree working directory materializer'
state: 'closed'
created_at: '2026-07-06T18:00:46Z'
updated_at: '2026-07-06T19:24:25Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-06T18:28:26Z'
---
## 背景
現在の Runtime は `--workspace` で与えられた単一 root を Worker の workspace scope として使っている。そのため、同じ repository root を使う Worker を複数 spawn すると worker allocation conflict が起きる。また、Runtime が source repository root を直接 Worker に渡しており、working directory materialization / cleanup / evidence / sandbox の境界がまだない。
Objective `00001KWW44EXK` では、Runtime が RepositoryPoint から Worker ごとの working directory を materialize し、Worker は source repository root ではなく materialized workspace を scope として起動する方針を定めた。この Ticket ではその v0 として、remote/cache-backed Git clone は扱わず、Repository `uri` が local Git repository を指し、そこから `git worktree add --detach` できる前提で最小の materializer を実装する。
ローカル origin の Git clone / Runtime repository-cache / bare mirror cache はこの Ticket では非目標とする。v0 は local source repository 自体を source cache 相当として扱い、Runtime root 配下に Worker ごとの detached worktree を作る。
## 要件
- Runtime は Worker spawn 前に working directory materializer を呼ぶ境界を持つ。
- v0 materializer は configured local Git repository から detached worktree を Runtime root 配下に作る。
- Worker は source repository root ではなく、materialized worktree path を workspace scope / cwd として起動する。
- `RepositoryId` / `RepositorySelector` / resolved `RepositoryPoint` の将来境界を壊さない型にする。
- この Ticket では local URI の Git repository のみ対応する。remote URI、bare mirror cache、clone/fetch、credential resolution は扱わない。
- worktree は branch 名ではなく resolved commit に対する detached worktree として作る。v0 では branch を作らず、Worker の変更は detached worktree 上の diff / patch artifact として回収する。branch 作成は後続の merge/export policy として扱う。
- dirty local changes は暗黙に含めない。v0 は `clean_point_only` とし、必要なら dirty state diagnostic を返す。
- working directory は `.yoi` や Backend workspace store ではなく Runtime root 配下に作る。
- Worker stop / cleanup 時に worktree cleanup できる方針または最低限の cleanup record を持つ。
- Browser-facing/API-facing diagnostics は raw host path を不必要に漏らさない。
## 受け入れ条件
- `CreateWorkerRequest` または Runtime-side spawn path に working directory materialization request / allocation 境界が追加されている。
- local Git repository `uri = "."` 相当から resolved commit を取得し、Runtime root 配下の `working-directories/<allocation-id>/root/<repository-id>``git worktree add --detach` できる。
- Worker 用の Git branch は v0 materialization では作成されず、変更回収は detached worktree の diff / patch artifact として扱われる。
- Worker spawn 時、Worker scope/cwd は source repository root ではなく materialized worktree を指す。
- 同じ local Git repository を対象に複数 Worker を spawn しても、source root scope の allocation conflict で失敗しない。
- created allocation には repository id、resolved commit/tree などの evidence に使える情報、materializer kind、cleanup target が記録される。
- unsupported non-local URI / non-Git provider は typed diagnostic で拒否される。
- dirty local workspace を暗黙に含めず、v0 policy として clean point only であることが test で分かる。
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
- `cargo test -p yoi-workspace-server` が通る。
- `cd web/workspace && deno task check && deno task test` が通る。
## 非目標
- remote Git URI の clone/fetch/cache を実装すること。
- local URI から Runtime repository-cache / bare mirror cache を作ること。
- CoW snapshot / Rift-like snapshot / reflink / overlay filesystem を実装すること。
- 強い container sandbox / network policy / secret distribution を完成させること。
- dirty working tree snapshot / patch artifact apply を実装すること。

View File

@ -0,0 +1,32 @@
完了。
実装内容:
- Runtime-side working directory materialization boundary を追加した。
- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `working-directories/<allocation-id>/root/<repository-id>` に detached Git worktree を作成するようにした。
- Worker spawn 時の workspace root / cwd / scope は source repository root ではなく materialized worktree path を使うようにした。
- 同一 source repository に対する複数 Worker が distinct materialized paths を使えるようにした。
- dirty source state は `clean_point_only` policy で拒否し、remote URI / non-Git provider / unsupported policy は typed diagnostics で fail closed するようにした。
- allocation evidence / cleanup target / cleanup policy / status を記録し、Worker stop cleanup hook で worktree cleanup と record update を行うようにした。
- Browser/API-facing spawn boundary は raw `WorkingDirectoryRequest` / `local_path` を受け取らないようにし、safe `repository_id` / optional `selector` から host/server 内部で materialization request を構築するようにした。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `8b7a5da0 feat: materialize working directories`
- review fix: `c90ebf24 fix: keep working directory paths internal`
- merge into orchestration: `c4cdf1c3 merge: working directory materializer`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
未実行:
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。

View File

@ -0,0 +1,364 @@
<!-- event: create author: "yoi ticket" at: 2026-07-06T18:00:46Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-06T18:26:52Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-06T18:26:52Z 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-06T18:28:26Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-06T18:29:42Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket は local Git worktree working directory materializer の v0 範囲、非目標、acceptance criteria が具体化されている。
- Objective `00001KWW44EXK` の境界Runtime root / working directory / RepositoryPoint / materialization strategy / cleanup registry / dirty state policyと整合している。
- `remote URI`, clone/fetch/cache, credential resolution, CoW/sandbox 強化、dirty snapshot/apply は非目標として明確。
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
Evidence checked:
- Ticket body / thread / artifacts。
- Objective `00001KWW44EXK` bodyRuntime working directory materialization direction, Runtime root separation, detached worktree strategy, clean point policy, cleanup registry
- `TicketRelationQuery(00001KWW9DYH4)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KWW9DYH4)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- visible Pods: prior repository-registry child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/worker-runtime/src/{runtime.rs,worker_backend.rs,execution.rs,fs_store.rs,http_server.rs,management.rs,config_bundle.rs}`, `crates/workspace-server/src/{repositories.rs,server.rs,config.rs}`, `crates/protocol/src/lib.rs`
IntentPacket:
Intent:
- Runtime Worker spawn の直前に working directory materialization boundary を追加し、configured local Git repository から Worker ごとの detached worktree を Runtime root 配下に作る v0 materializer を実装する。
- Worker の workspace scope / cwd を source repository root ではなく materialized worktree path にすることで、同じ local Git repository を対象に複数 Worker を spawn しても source root scope allocation conflict で失敗しないようにする。
Binding decisions / invariants:
- working directory は `.yoi` / Backend workspace store ではなく Runtime root 配下に作る。
- v0 は local Git repository URI のみ対応する。remote URI / non-Git provider / clone/fetch/cache / credentials は typed diagnostic で拒否または非対応扱い。
- worktree は branch 名ではなく resolved commit に対する detached worktree として作る。v0 materialization では Worker 用 branch を作らない。
- dirty local changes は暗黙に含めない。v0 は `clean_point_only` とし、dirty state は typed diagnostic / test evidence で明示する。
- created allocation には repository id、resolved commit/tree など evidence、materializer kind、cleanup target、status/cleanup policy に相当する record を持たせる。
- Worker stop / cleanup 時に worktree cleanup できる方針または最低限の cleanup record を持つ。
- Browser/API-facing diagnostics は raw host path を不必要に漏らさない。
- `RepositoryId` / `RepositorySelector` / `RepositoryPoint` の将来境界を壊さず、Git branch/tag/hash 固定の抽象にしない。
Requirements / acceptance criteria:
- `CreateWorkerRequest` または Runtime-side spawn path に working directory materialization request / allocation 境界が追加されている。
- local Git repository `uri = "."` 相当から resolved commit を取得し、Runtime root 配下の `working-directories/<allocation-id>/root/<repository-id>``git worktree add --detach` できる。
- Worker spawn 時の scope/cwd は source repository root ではなく materialized worktree を指す。
- 同じ local Git repository を対象に複数 Worker を spawn しても source root scope conflict で失敗しない。
- unsupported non-local URI / non-Git provider は typed diagnostic。
- dirty local workspace を暗黙に含めず clean-point-only policy であることが tests で分かる。
Implementation latitude:
- Runtime-side 型名、module 分割、allocation record の永続化形式、cleanup status 名は既存 `worker-runtime` style に合わせてよい。
- v0 で Runtime repository cache/bare mirror を作らず、configured local repository 自体を source cache 相当として扱ってよい。
- cleanup は full lifecycle 完成ではなく、最低限の record と stop/cleanup hook を優先してよい。ただし orphan worktree cleanup の方向が明示されていること。
- Browser/API response は detailed host paths を避け、debug/internal logs/tests では必要に応じて raw path を扱ってよい。
Escalate if:
- remote clone/fetch/cache, credentials, dirty snapshot/apply, synthetic branch/export policy, strong sandbox/container/network policy, multi-repository mount, or Backend store migration が必要になる場合。
- Runtime / Worker launch protocol の public boundary を広げ、raw source path/secret/internal runtime path を Browser-facing API に出す必要がある場合。
- RepositoryPoint / RepositorySelector の semantic boundary を変更する必要がある場合。
Validation:
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `cd web/workspace && deno task check && deno task test`
- `git diff --check`
- `yoi ticket doctor`
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更がある場合だけ実行し、未実行なら理由を報告する。
Current code map:
- Runtime worker spawn/control: `crates/worker-runtime/src/runtime.rs`, `worker_backend.rs`, `execution.rs`, `http_server.rs`, `management.rs`
- Runtime persistence/root: `crates/worker-runtime/src/fs_store.rs`, `catalog.rs`, `config_bundle.rs`
- Repository registry / point context: `crates/workspace-server/src/repositories.rs`, `config.rs`, `server.rs`
- Protocol/request shapes: `crates/protocol/src/lib.rs` and worker-runtime request/response structs。
Critical risks / reviewer focus:
- No source repository root scope/cwd is passed to Worker as the normal path after materialization。
- detached worktree is created at resolved commit, not branch checkout。
- dirty source repository state is rejected or clearly excluded by clean_point_only tests。
- allocation record/evidence/cleanup target is present and not only an ephemeral path local variable。
- unsupported providers/remotes fail closed with typed sanitized diagnostics。
- materializer boundary remains strategy-extensible and does not hard-code Git worktree as the only future model。
---
<!-- event: state_changed author: orchestrator at: 2026-07-06T18:30:00Z 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。
- Objective `00001KWW44EXK` の working directory materialization strategy / dirty policy / Runtime root separation / cleanup registry context。
- `TicketRelationQuery(00001KWW9DYH4)`: blocking relation 0 件。
- `TicketOrchestrationPlanQuery(00001KWW9DYH4)`: prior record 0 件。今回 accepted_plan を記録済み。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
- visible Pods / TicketDoctor / bounded code map を確認。
Acceptance basis:
- concrete missing decision / information は残っていない。
- Ticket の scope は local Git detached worktree materializer v0 に閉じており、remote clone/fetch/cache, credentials, dirty snapshot/apply, strong sandbox 等は非目標として明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T18:31:11Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWW9DYH4-execution-workspace-materializer`
- Branch: `work/00001KWW9DYH4-execution-workspace-materializer`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KWW9DYH4-exec-workspace` を起動した。
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T18:57:42Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `8b7a5da0 feat: materialize working directories` on branch `work/00001KWW9DYH4-execution-workspace-materializer`.
- Worktree status was clean after commit.
- Changed files include:
- `crates/worker-runtime/src/working_directory.rs` (new)
- `crates/worker-runtime/src/{catalog.rs,execution.rs,http_server.rs,lib.rs,main.rs,runtime.rs,worker_backend.rs}`
- `crates/workspace-server/src/{companion.rs,hosts.rs,server.rs}`
- Implementation summary:
- Runtime-side working directory request/status types and local Git worktree materializer。
- detached Git worktree creation under Runtime root: `working-directories/<allocation-id>/root/<repository-id>`
- clean HEAD/selector commit and tree evidence resolution。
- dirty source rejection under `clean_point_only`
- typed diagnostics for remote URI / non-Git provider / unsupported policy。
- materialization/cleanup evidence persistence and best-effort worktree cleanup on Worker stop。
- Worker spawn cwd/scope uses materialized worktree path, not source repository root。
- workspace-server embedded runtime startup and Worker spawn pass configured repositories into materialization request。
- API-facing working directory status summaries remain sanitized/path-free。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass63 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
---
<!-- event: review author: reviewer at: 2026-07-06T19:06:24Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blocker:
- 実装が Browser/API-facing spawn boundary を広げ、API caller から full `WorkingDirectoryRequest``WorkingDirectoryRepository.local_path: Option<PathBuf>` を含む)を受け取れる形になっている。
- `crates/workspace-server/src/hosts.rs:263-283``WorkerSpawnRequest` を browser-safe として raw workspace roots/storage paths を受け取らない契約にしているが、現在は `working_directory: Option<WorkingDirectoryRequest>` を含む。
- `crates/worker-runtime/src/catalog.rs:58-66` の request は raw `local_path` を運べる。
- `/api/runtimes/{runtime_id}/workers` はこの request shape を受け取り、embedded/remote host spawn path が `CreateWorkerRequest` に pass-through している。
- これは Ticket / routing invariant の「raw source/internal paths を含む public launch boundary 拡張を避ける。必要なら escalate」に反し、既存コード上の browser-safe contract とも衝突している。
- safer `/api/workers` path は server-side repository config から `repository_id` により解決しているため、generic API も caller-supplied raw `local_path` authority を受け取らない形にするか、working directory materialization request の組み立てを host/runtime 内部境界に閉じる必要がある。
Evidence supporting the rest of the implementation:
- Runtime/backend materialization boundary は存在する。`Runtime::create_worker` が request を backend に渡し、`WorkerRuntimeExecutionBackend::spawn_worker` が spawn 前に materializer を呼ぶ。
- local materializer は `working-directories/<allocation-id>/root/<repository-id>` 配下に resolved commit の detached worktree を作成し、branch checkout はしない。
- Worker `workspace_root` / `cwd``Worker::from_manifest_with_context` 前に materialized binding に差し替えられている。
- dirty source は `clean_point_only` で拒否され、remote/non-Git は typed diagnostic code を返す。
- allocation evidence/status/cleanup target/policy は記録・永続化され、worker stop cleanup は worktree removal / cleanup-pending 更新を行う。
- tests は detached materialization、distinct paths、dirty rejection、unsupported diagnostics、cleanup、spawn cwd substitution を cover している。
Validation reviewed:
- Ticket / Objective と implementation diff/files を implementation worktree 内で read-only review。
- Orchestrator reported validation pass`git diff --check`, worker-runtime/workspace-server tests, `cargo check -p yoi`, web checks/tests, `yoi ticket doctor`)を確認。
Non-blocking follow-ups:
- `Runtime::stop_runtime` は currently workers を stopped に mark するだけなので、per-worker stop 以外の runtime-stop/orphan-sweeper cleanup を後続で検討するとよい。
- external HTTP/API error body で materializer diagnostic code を message string だけでなく structured に保つ余地がある。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T19:17:17Z -->
## Implementation report
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `c90ebf24 fix: keep working directory paths internal` on branch `work/00001KWW9DYH4-execution-workspace-materializer`.
- Worktree status was clean after commit.
- Fix summary:
- Browser/API-facing `WorkerSpawnRequest` no longer deserializes full `WorkingDirectoryRequest` or caller-supplied raw `local_path`.
- Added browser-safe working directory request shape with `repository_id` and optional `selector` only。
- Added `deny_unknown_fields` so raw fields such as `working_directory.local_path` and unexpected fields are rejected at the API boundary。
- Server-side host code resolves configured repository registry into internal `WorkingDirectoryRequest`; raw `local_path` construction stays behind trusted host/server boundary。
- Existing `/api/workers` repository-id safe behavior remains preserved。
- Added focused API tests for rejecting raw `working_directory.local_path` and accepting safe repository-id/selector materialization without exposing raw workspace path。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
- Requesting follow-up external review against `8b7a5da0..c90ebf24` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-06T19:23:01Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Evidence reviewed:
- Ticket / Objective context, combined diff, fix diff, and relevant runtime/workspace-server files in the implementation worktree。
- Fix commit `c90ebf24` and combined implementation after `8b7a5da0`
Findings:
- Prior blocker is resolved. Browser/API `WorkerSpawnRequest` now exposes only `working_directory: { repository_id, selector? }` via `WorkerSpawnWorkingDirectoryRequest` with `deny_unknown_fields`; internal `resolved_working_directory` is `#[serde(skip, default)]`
- `/api/runtimes/{runtime_id}/workers` resolves safe repository id/selector server-side from configured repositories into an internal `WorkingDirectoryRequest`
- `/api/workers` remains safe: `BrowserCreateWorkerRequest` only accepts optional `repository_id`; server-side code builds the internal request。
- Fix tests explicitly reject caller-supplied `local_path` and accept safe `repository_id`/`selector`, with response path redaction asserted。
- Response/status projections remain browser-safe: working directory summaries carry ids/commit/tree/cleanup metadata, not raw workspace/source paths。
- Original materializer acceptance remains satisfied:
- local Git only, remote/non-Git/dirty source rejected with typed diagnostics。
- selector resolves to commit/tree。
- detached `git worktree add --detach` is created under Runtime root `working-directories/<allocation-id>/root/<repository-id>`
- worker factory uses materialized workspace root/cwd instead of source root。
- allocation evidence and cleanup target are recorded。
- cleanup hook removes worktree and updates record。
- tests cover detached worktree, distinct paths, dirty rejection, unsupported diagnostics, cleanup, and materialized cwd。
Validation reviewed:
- Reviewer inspected requested fix diff and combined implementation diff。
- Reviewer ran read-only `git diff --check 50c80e77..HEAD` in the implementation worktree; no whitespace errors。
- Reviewer accepted Orchestrator-reported full validation suite as passing。
Non-blocking note:
- Runtime HTTP `/v1/workers` remains an internal/trusted backend-to-runtime API carrying `CreateWorkerRequest`; this matches the current trusted-runtime boundary and is not browser-facing。
No merge-blocking issues found.
---
<!-- event: state_changed author: orchestrator at: 2026-07-06T19:24:12Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fix。
- Implementation branch `work/00001KWW9DYH4-execution-workspace-materializer` was merged into the orchestration branch with merge commit `c4cdf1c3 merge: working directory materializer`
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
Implementation commits:
- `8b7a5da0 feat: materialize working directories`
- `c90ebf24 fix: keep working directory paths internal`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-06T19:24:25Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-06T19:24:25Z status: closed -->
## 完了
完了。
実装内容:
- Runtime-side working directory materialization boundary を追加した。
- v0 local Git materializer として、configured repository から resolved commit / tree evidence を取り、Runtime root 配下 `working-directories/<allocation-id>/root/<repository-id>` に detached Git worktree を作成するようにした。
- Worker spawn 時の workspace root / cwd / scope は source repository root ではなく materialized worktree path を使うようにした。
- 同一 source repository に対する複数 Worker が distinct materialized paths を使えるようにした。
- dirty source state は `clean_point_only` policy で拒否し、remote URI / non-Git provider / unsupported policy は typed diagnostics で fail closed するようにした。
- allocation evidence / cleanup target / cleanup policy / status を記録し、Worker stop cleanup hook で worktree cleanup と record update を行うようにした。
- Browser/API-facing spawn boundary は raw `WorkingDirectoryRequest` / `local_path` を受け取らないようにし、safe `repository_id` / optional `selector` から host/server 内部で materialization request を構築するようにした。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `8b7a5da0 feat: materialize working directories`
- review fix: `c90ebf24 fix: keep working directory paths internal`
- merge into orchestration: `c4cdf1c3 merge: working directory materializer`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass34 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass15 tests
- `yoi ticket doctor`: ok
未実行:
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260706-195421-1","ticket_id":"00001KWWE8E04","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWWE8E04 は独立 queued Ticket で blocker なし。Workspace Browser の canonical route/API を `/w/<workspace-id>` / `/api/w/<workspace-id>` に scope する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWWE8E04-workspace-id-routes","worktree":"/home/hare/Projects/yoi/.worktree/00001KWWE8E04-workspace-id-routes","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、workspace id scoped Browser routes/API/backend mismatch guard/frontend link/API helpers/tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-06T19:54:21Z"}

View File

@ -0,0 +1,68 @@
---
title: 'Scope Workspace Browser routes and API by workspace id'
state: 'closed'
created_at: '2026-07-06T19:25:08Z'
updated_at: '2026-07-06T20:53:44Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-06T19:53:14Z'
---
## 背景
現在の Workspace Browser は 1 Backend = 1 Workspace を前提に、Frontend route と Browser-facing API のどちらも暗黙の current Workspace を参照している。
```text
/
/repositories/<repository-id>
/objectives
/settings
/runtimes/<runtime-id>/workers/<worker-id>/console
/api/workspace
/api/repositories
/api/objectives
/api/settings/runtime-connections
/api/workers
```
この形では、将来 1 Backend process が複数 Workspace を扱う設計に進んだとき、URL / API authority / Sidebar data / Worker console context がどの Workspace に属するのか曖昧になる。1 Backend = 1 Workspace の間でも、Browser URL と API contract は Workspace id を含む形にしておく。
Workspace URL は slug/renameable handle ではなく immutable Workspace id を使う。多少長い id は許容し、URL の安定性を優先する。Route prefix は短く `/w/<workspace-id>/...` とする。Backend 管理 UI や global settings は将来 `/admin` / `/settings` / `/backend` など別 top-level に置ける。
同時に、Browser から叩く SvelteKit/Backend product API も Workspace id scope を持たせる。UI route だけ Workspace scoped にして API が `/api/...` のままだと、暗黙 current Workspace が残るため、Frontend は scoped API を使う。
## 要件
- Browser routes を `/w/<workspace-id>/...` に移す。
- Browser-facing API に `/api/w/<workspace-id>/...` を追加する。
- 1 Backend = 1 Workspace の現状でも、request path の `workspace-id` と Backend が serving している `workspace_id` を照合する。
- workspace id mismatch は typed sanitized error として返す。Browser-facing には raw path / internal store / authority-bearing internals を漏らさない。
- Frontend は route params の `workspaceId` を使う workspace-scoped API client/helper から API を呼ぶ。
- Sidebar / settings / repository / objectives / worker console の links は workspace id を含む。
- 既存 unscoped API は互換 alias として残してよいが、Frontend からは scoped API を使う。
- 既存 unscoped routes は redirect するか、互換を残す場合も canonical route は `/w/<workspace-id>/...` とする。
- Runtime API (`/v1/runtime`, `/v1/workers`) とは混同しない。この Ticket の API scope は Browser-facing Workspace Backend API の話である。
## 受け入れ条件
- `/w/<workspace-id>` が現行 overview を表示する。
- `/w/<workspace-id>/repositories/<repository-id>` が Repository detail を表示する。
- `/w/<workspace-id>/objectives``/w/<workspace-id>/objectives/<objective-id>` が動く。
- `/w/<workspace-id>/settings` が Runtime/Workspace settings を表示する。
- `/w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console` が Worker console を表示する。
- Frontend の API 呼び出しは `/api/w/<workspace-id>/...` を使う。
- `/api/w/<workspace-id>/workspace`, `/repositories`, `/objectives`, `/workers`, `/settings/runtime-connections` など主要 Browser-facing API が scoped path で動く。
- `workspace-id` が Backend の current `workspace_id` と違う場合は typed 404 または equivalent mismatch diagnostic を返す。
- Sidebar navigation links と Worker console links は workspace id を含む。
- 旧 unscoped route/API を互換として残す場合でも、Frontend test は scoped route/API を期待する。
- `cd web/workspace && deno task check && deno task test` が通る。
- `cargo test -p yoi-workspace-server` が通る。
## 非目標
- Backend の canonical store を multi-workspace DB に移行すること。
- `/api/workspaces/<workspace-id>/...` 形式の long form API を同時に実装すること。
- Workspace slug / handle / rename / alias 機能を実装すること。
- Runtime `/v1/...` API に workspace id を path segment として入れること。
- Backend global admin UI を完成させること。

View File

@ -0,0 +1,32 @@
完了。
実装内容:
- Workspace Browser の canonical UI routes を `/w/<workspace-id>/...` に移行した。
- Browser-facing Workspace API に `/api/w/<workspace-id>/...` scoped variants を追加した。
- Backend current immutable `workspace_id` と path `workspace-id` を照合し、mismatch は typed sanitized `workspace_id_mismatch` diagnostic / 404 として fail closed するようにした。
- Frontend の workspace-scoped data calls は `/api/w/<workspace-id>/...` helper 経由に変更した。
- Sidebar / settings / repository / objectives / worker console links は workspace id を含む scoped links を使うようにした。
- Runtime `/v1/...` API surface は変更しなかった。
- Unscoped browser routes は `/api/workspace` bootstrap から scoped `/w/<workspace-id>/...` へ redirect する thin shim とし、通常の unscoped data route / duplicated co-canonical page surface を廃止した。
- Backend SPA fallback でも unscoped Browser routes を scoped route に redirect するようにした。
- Focused backend/web tests を追加/更新した。
主な commit / merge:
- implementation: `f6ad9cfc feat: scope workspace routes by id`
- review fix: `71340b89 fix: redirect unscoped workspace routes`
- merge into orchestration: `13e756e7 merge: workspace id route scoping`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
未実行:
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。

View File

@ -0,0 +1,344 @@
<!-- event: create author: "yoi ticket" at: 2026-07-06T19:25:08Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-06T19:48:42Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-06T19:48:42Z 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-06T19:53:14Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-06T19:54:13Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket は Workspace Browser route/API を immutable workspace id で scope する具体的な URL/API contract、acceptance criteria、非目標を明記している。
- 1 Backend = 1 Workspace の現状でも request path の `workspace-id` と Backend current `workspace_id` を照合するという authority boundary が明確。
- Runtime `/v1/...` API とは混同しないこと、multi-workspace DB migration / slug / admin UI は非目標であることが明確。
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KWWE8E04)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KWWE8E04)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- visible Pods: previous working-directory child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/workspace-server/src/{identity.rs,server.rs,hosts.rs,config.rs,records.rs,repositories.rs}`, `web/workspace/src/routes/**`, `web/workspace/src/lib/workspace-sidebar/**`, workspace settings/sidebar/console helpers。
IntentPacket:
Intent:
- Workspace Browser の canonical UI route を `/w/<workspace-id>/...` に移し、Frontend が Browser-facing API を `/api/w/<workspace-id>/...` 経由で呼ぶようにする。
- 1 Backend = 1 Workspace の現状でも、path workspace id と serving workspace id を照合し、mismatch を typed sanitized diagnostic として fail closed する。
Binding decisions / invariants:
- Workspace URL は slug/renameable handle ではなく immutable `workspace_id` を使う。
- canonical route prefix は短い `/w/<workspace-id>/...`
- Browser-facing scoped API prefix は `/api/w/<workspace-id>/...`
- Runtime API `/v1/runtime`, `/v1/workers` はこの Ticket の scope 外であり、workspace id segment を追加しない。
- Backend canonical store の multi-workspace DB migration、Workspace slug/alias/rename、global admin UI は非目標。
- 既存 unscoped API/routes は互換 alias / redirect として残してよいが、Frontend の own calls/tests は scoped API/route を期待する。
- workspace id mismatch response は typed sanitized error とし、raw path / internal store / authority-bearing internals を漏らさない。
Requirements / acceptance criteria:
- `/w/<workspace-id>` が overview を表示する。
- `/w/<workspace-id>/repositories/<repository-id>` が Repository detail を表示する。
- `/w/<workspace-id>/objectives``/w/<workspace-id>/objectives/<objective-id>` が動く。
- `/w/<workspace-id>/settings` が Settings を表示する。
- `/w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console` が Worker console を表示する。
- Frontend API calls use `/api/w/<workspace-id>/...` for workspace/repositories/objectives/workers/settings runtime-connections and relevant worker console endpoints。
- Sidebar/settings/repository/objectives/worker-console links include workspace id。
- mismatched workspace id returns typed 404 or equivalent mismatch diagnostic。
- Focused backend/web tests cover scoped API, mismatch, and frontend scoped API/link construction。
Implementation latitude:
- Route grouping/layout structure, helper naming, redirect vs compatibility route choice, and scoped API client abstraction can follow current SvelteKit/backend style。
- Existing unscoped API can remain as compatibility alias if scoped variants are canonical for frontend use。
- A small workspace route helper/client module is acceptable to avoid string duplication。
- Tests may cover helper/model/API routing rather than full browser E2E, consistent with current project constraints。
Escalate if:
- Multi-workspace DB/store authority must be implemented to satisfy routing。
- Workspace slug/handle/rename/alias becomes necessary。
- Runtime `/v1/...` API needs workspace id path segment。
- Browser-facing mismatch diagnostics require exposing raw internal paths/store/runtime details。
Validation:
- `cd web/workspace && deno task check && deno task test`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- `yoi ticket doctor`
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更がある場合だけ実行し、未実行なら理由を報告する。
Current code map:
- Workspace identity: `crates/workspace-server/src/identity.rs`, config/server setup in `config.rs`, `main.rs`
- Backend routes/API: `crates/workspace-server/src/server.rs`, host/worker API context in `hosts.rs`, project records in `records.rs`, repository API in `repositories.rs`
- Frontend routes: `web/workspace/src/routes/+layout.ts`, `+layout.svelte`, `+page.ts`, `+page.svelte`, `routes/repositories/[repositoryId]`, `routes/objectives`, `routes/settings`, `routes/runtimes/[runtimeId]/workers/[workerId]/console`
- Frontend navigation/API call surfaces: `web/workspace/src/lib/workspace-sidebar/**`, settings model/page, worker console page, repository navigation helpers。
Critical risks / reviewer focus:
- Frontend must not keep calling unscoped `/api/...` for workspace-scoped data。
- Links must include the route `workspaceId`; no hardcoded current workspace routes should remain as canonical links。
- Scoped API mismatch must fail closed with sanitized typed diagnostics。
- Runtime `/v1/...` API must remain unchanged。
- Compatibility aliases must not become the frontend canonical path。
- workspace id should be treated as immutable id, not slug/alias。
---
<!-- event: state_changed author: orchestrator at: 2026-07-06T19:54:30Z 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(00001KWWE8E04)`: blocking relation 0 件。
- `TicketOrchestrationPlanQuery(00001KWWE8E04)`: prior record 0 件。今回 accepted_plan を記録済み。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
- visible Pods / TicketDoctor / bounded code map を確認。
Acceptance basis:
- concrete missing decision / information は残っていない。
- Ticket の scope は Browser route/API workspace-id scoping に閉じており、multi-workspace DB migration / slug/alias / Runtime `/v1` scope change は非目標として明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T19:55:26Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWWE8E04-workspace-id-routes`
- Branch: `work/00001KWWE8E04-workspace-id-routes`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KWWE8E04-workspace-routes` を起動した。
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T20:27:15Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `f6ad9cfc feat: scope workspace routes by id` on branch `work/00001KWWE8E04-workspace-id-routes`.
- Worktree status was clean after commit.
- Changed files include backend `crates/workspace-server/src/server.rs` / `lib.rs`, scoped route/API helpers/tests, sidebar/repository/objective/settings/worker console links, and new SvelteKit route files under `web/workspace/src/routes/w/[workspaceId]/...`.
- Implementation summary:
- Added canonical Browser routes under `/w/<workspace-id>/...`
- Added Browser-facing scoped API routes under `/api/w/<workspace-id>/...`
- Added backend scoped workspace id validation against current immutable backend workspace id。
- mismatch returns typed sanitized `workspace_id_mismatch` diagnostic with 404。
- Runtime `/v1/...` APIs were not changed。
- Frontend links/API calls now use scoped route/API helpers for workspace Browser surfaces。
- Worker creation console hrefs point at scoped `/w/<workspace-id>/runtimes/.../console`
- Focused backend and frontend tests cover scoped API/mismatch and route/API/link helpers。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass16 tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
---
<!-- event: review author: reviewer at: 2026-07-06T20:36:50Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blockers:
1. Unscoped UI routes still perform unscoped frontend API calls。
- `web/workspace/src/routes/+layout.ts:11-18` falls back to `/api${path}` when `params.workspaceId` is absent。
- As a result `/`, `/objectives`, `/settings` などが workspace-scoped data を `/api/workspace` / `/api/repositories` から通常 load し続ける。
- これは Ticket / Intent の「Frontend own calls は workspace-scoped data に `/api/w/<workspace-id>/...` を使う」と衝突する。Compatibility aliases は redirect/bootstrap-only などにして、通常の unscoped data routes として残さない必要がある。
2. scoped/unscoped browser routes が大きな duplicated pages として実装され、route logic drift を作っている。
- `web/workspace/src/routes/w/[workspaceId]/**` に full page copies が追加され、旧 route files も残っている。
- `settings/+page.svelte``w/[workspaceId]/settings/+page.svelte`、worker console page などが byte-identical。
- これは critical pages の substantial duplicated UI/control logic であり、thin compatibility shim ではなく co-canonical surface になっている。
- Unscoped routes を `/w/<workspace-id>/...` に redirect するか、shared route components/load helpers に factor して canonical scoped routes だけを maintained surface にする必要がある。
Validated/inspected:
- Ticket item/thread と routing IntentPacket。
- implementation worktree 内で `f6ad9cfc` diff/stat/check を inspection。
- Backend scoped route pattern と mismatch handling は方向性として妥当。`validate_workspace_scope` は fail closed し、`workspace_id_mismatch` diagnostics は raw path/internal store details を漏らしていない。
- Runtime `/v1/...` API は inspected diff では変更されていない。
- Orchestrator-reported validations は pass として確認。Reviewer は heavy checks を再実行していない。
Non-blocking follow-ups:
- 一部 UI placeholder/help text が旧 `/api/...` paths に触れているため、canonical route/API behavior 修正後に掃除するとよい。
- redirect/shared-route refactor 後、scoped settings/workers/console endpoints の direct coverage を増やすとよい。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-06T20:45:34Z -->
## Implementation report
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `71340b89 fix: redirect unscoped workspace routes` on branch `work/00001KWWE8E04-workspace-id-routes`.
- Worktree status was clean after commit.
- Fix summary:
- `+layout.ts` now uses `/api/workspace` only as bootstrap when route `workspaceId` is absent, then redirects to `/w/<workspace-id>...`
- Silent `/api${path}` fallback for workspace-scoped data was removed。
- Unscoped root/objectives/repository/settings/worker-console routes are thin redirect/shim surfaces instead of duplicated full data/control pages。
- Backend SPA fallback redirects unscoped Browser routes such as `/objectives?...` to `/w/<workspace-id>/objectives?...`
- Canonical maintained page/control logic remains under `/w/[workspaceId]/**`
- Added/updated focused tests for bootstrap-then-redirect behavior, no unscoped workspace API fallback, thin settings shim, backend unscoped UI redirect, and canonical worker console scoped route references。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
- Requesting follow-up external review against `f6ad9cfc..71340b89` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-06T20:52:40Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Evidence reviewed in implementation worktree:
- Ticket `00001KWWE8E04`, fix commit `71340b89`, and combined relevant implementation。
Findings:
- Prior blocker 1 resolved: unscoped browser routes now bootstrap only through `/api/workspace` and redirect to `/w/<workspace-id>/...`; when no workspace id is available, layout returns empty/null state instead of loading workspace datasets through unscoped `/api/...`
- Prior blocker 2 resolved: canonical maintained UI/control logic is under `web/workspace/src/routes/w/[workspaceId]/...`; unscoped route files are thin empty-load/component shims and no longer duplicate critical page logic。
- Frontend scoped API construction uses `/api/w/<workspace-id>/...` via shared helpers; scoped routes and links use `/w/<workspace-id>/...`
- Backend has scoped `/api/w/{workspace_id}/...` routes with centralized workspace-id assertion. Mismatches return typed/sanitized 404 diagnostic (`workspace_id_mismatch`) without raw paths, store paths, or runtime internals。
- Runtime `/v1/...` API surface was not touched。
- Tests meaningfully cover scoped API behavior, mismatch diagnostics, static unscoped redirect behavior, frontend scoped API/link construction, and unscoped shim/source-shape expectations。
Validation note:
- Reviewer did not rerun the full validation; reviewer relied on Orchestrator-reported passing validation and performed read-only inspection。
No merge-blocking issues found.
---
<!-- event: state_changed author: orchestrator at: 2026-07-06T20:53:31Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fix。
- Implementation branch `work/00001KWWE8E04-workspace-id-routes` was merged into the orchestration branch with merge commit `13e756e7 merge: workspace id route scoping`
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
Implementation commits:
- `f6ad9cfc feat: scope workspace routes by id`
- `71340b89 fix: redirect unscoped workspace routes`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-06T20:53:44Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-06T20:53:44Z status: closed -->
## 完了
完了。
実装内容:
- Workspace Browser の canonical UI routes を `/w/<workspace-id>/...` に移行した。
- Browser-facing Workspace API に `/api/w/<workspace-id>/...` scoped variants を追加した。
- Backend current immutable `workspace_id` と path `workspace-id` を照合し、mismatch は typed sanitized `workspace_id_mismatch` diagnostic / 404 として fail closed するようにした。
- Frontend の workspace-scoped data calls は `/api/w/<workspace-id>/...` helper 経由に変更した。
- Sidebar / settings / repository / objectives / worker console links は workspace id を含む scoped links を使うようにした。
- Runtime `/v1/...` API surface は変更しなかった。
- Unscoped browser routes は `/api/workspace` bootstrap から scoped `/w/<workspace-id>/...` へ redirect する thin shim とし、通常の unscoped data route / duplicated co-canonical page surface を廃止した。
- Backend SPA fallback でも unscoped Browser routes を scoped route に redirect するようにした。
- Focused backend/web tests を追加/更新した。
主な commit / merge:
- implementation: `f6ad9cfc feat: scope workspace routes by id`
- review fix: `71340b89 fix: redirect unscoped workspace routes`
- merge into orchestration: `13e756e7 merge: workspace id route scoping`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass65 lib tests + 2 main tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
未実行:
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260707-134133-1","ticket_id":"00001KWY8EHEJ","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWY8EHEJ は独立 queued Ticket で blocker なし。先行 Runtime materializer と workspace-id scoped API を前提に、Browser-managed pre-allocated working directory を Worker spawn に接続する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWY8EHEJ-browser-working-directories","worktree":"/home/hare/Projects/yoi/.worktree/00001KWY8EHEJ-browser-working-directories","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、workspace-scoped Browser-managed working directory API / Worker launch UI integration / allocation-id spawn connection / relative_cwd validation / cleanup/status tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-07T13:41:33Z"}

View File

@ -0,0 +1,40 @@
---
title: 'Enable Browser-managed working directories for Worker spawn'
state: 'closed'
created_at: '2026-07-07T12:22:06Z'
updated_at: '2026-07-07T14:58:35Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-07T13:40:23Z'
---
## 背景
Runtime 側には `CreateWorkerRequest.working_directory` と local Git worktree materializer があり、Worker 作成時に RepositoryPoint 相当の入力から detached worktree を materialize して Worker の workspace/cwd/scope にする境界が既にある。一方、Browser UI からは作業環境を事前に用意・確認し、それを Worker spawn 操作で選択する経路がまだ無い。
Browser から raw host path や内部 materialization request を直接指定させるのではなく、Workspace Backend が repository / selector / policy を検証して working directory を作成し、Browser には allocation id と安全な summary だけを返す形にする。
## 要件
- Browser-facing API で working directory を作成・一覧・詳細確認できる。
- v0 の materializer は既存方針どおり local Git repository からの detached worktree とし、remote clone/cache や dirty source inclusion は扱わない。
- Browser-facing payload に backend-private absolute path、raw materialization request、内部 runtime path を露出しない。
- Worker spawn form/API から、作成済み working directory を選択して Worker 作成に利用できる。
- Worker が利用する cwd 指定は host absolute path ではなく、materialized workspace root からの安全な `relative_cwd` として扱う。
- `relative_cwd` は absolute path、`..` escape、symlink escape、存在しない/不正な working directory を拒否する。
- Worker detail / list には working directory の safe summary を表示する。
- cleanup policy と手動 cleanup 操作の v0 方針を明示する。
## 受け入れ条件
- `POST /api/w/<workspace-id>/working-directories` 相当の API で configured repository と selector から working directory を作成できる。
- `GET /api/w/<workspace-id>/working-directories` 相当の API で作成済み working directory の safe summary を一覧できる。
- Worker launch UI で working directory を選択でき、spawn request が raw path ではなく safe id/selector 情報を送る。
- Worker spawn 時に選択した working directory が Runtime の `CreateWorkerRequest.working_directory` または同等の backend-resolved request に接続される。
- 作成された Worker の workspace/cwd/scope が materialized worktree 配下になり、Browser/API には内部 path が漏れない。
- 不正な repository、dirty unsupported source、remote unsupported source、invalid `relative_cwd` は typed diagnostic として拒否される。
- frontend check/test、workspace-server tests、`yoi ticket doctor`、`nix build .#yoi` が通る。
## 設計メモ
初期実装は pre-allocate 方式を優先する。つまり Browser で working directory を明示的に作成し、Worker spawn では `allocation_id` と任意の `relative_cwd` を選択する。単発 spawn request だけで暗黙 materialize する経路は、UI/cleanup/diagnostics が固まるまで後回しにする。

View File

@ -0,0 +1,35 @@
完了。
実装内容:
- Browser-managed working directory API を scoped Workspace API に追加した。
- `POST /api/w/<workspace-id>/working-directories`
- `GET /api/w/<workspace-id>/working-directories`
- detail / cleanup endpoint
- Browser create payload は configured `repository_id` / selector / policy を使い、raw host path / raw internal `WorkingDirectoryRequest` / internal runtime path を受け取らないようにした。
- Browser response は allocation id、repository id、selector/commit evidence、status、cleanup policy など safe summary のみを返すようにした。
- Worker launch UI で working directory を create/select できるようにした。
- Worker spawn は selected allocation id + optional `relative_cwd` を使い、Runtime materialized workspace に接続するようにした。
- `relative_cwd` は materialized root 相対として検証し、absolute path、`..` escape、symlink escape、nonexistent / non-directory を拒否するようにした。
- invalid `relative_cwd` は Browser-facing API で typed `working_directory_relative_cwd_*` diagnostic として返るようにした。
- Worker list/detail に working directory safe summary を含めた。
- cleanup は allocation root に bounded され、typed/sanitized diagnostic を返すようにした。
- Focused backend/runtime/web tests を追加した。
主な commit / merge:
- implementation: `684b19e8 feat: add browser working directories`
- review fix: `9adb0fae fix: preserve working directory cwd diagnostics`
- merge into orchestration: `b52986e5 merge: browser working directories`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass

View File

@ -0,0 +1,354 @@
<!-- event: create author: "yoi ticket" at: 2026-07-07T12:22:06Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-07T12:26:36Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-07T12:26:36Z 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-07T13:40:23Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-07T13:41:22Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket は Browser-managed working directory の pre-allocate 方式、API/UI/spawn 接続、`relative_cwd` validation、path leak invariant、cleanup policy の v0 方針を具体化している。
- 先行 Runtime-side materializer / Repository registry / workspace-id scoped API が orchestration branch に統合済みで、Browser-facing API を `/api/w/<workspace-id>/...` に追加する実装基盤がある。
- raw host path / internal materialization request を Browser payload に露出しない binding invariant が明確で、Worker spawn は allocation id + safe `relative_cwd` で接続する要求になっている。
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KWY8EHEJ)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KWY8EHEJ)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- visible Pods: previous child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/worker-runtime/src/{catalog.rs,working_directory.rs,runtime.rs,worker_backend.rs,http_server.rs}`, `crates/workspace-server/src/{server.rs,hosts.rs,repositories.rs,config.rs}`, `web/workspace/src/lib/workspace-sidebar/**`, scoped routes/API under `web/workspace/src/routes/w/[workspaceId]/**`
IntentPacket:
Intent:
- Browser-facing API から configured repository + selector + policy により working directory を事前作成し、Browser には allocation id と safe summary だけを返す。
- Worker launch UI/API で作成済み working directory を選択し、Worker spawn 時に allocation id と safe `relative_cwd` を Runtime materializer / Worker spawn boundary に接続する。
- Worker detail/list に working directory の safe summary を表示し、cleanup policy と手動 cleanup 操作の v0 方針を実装/明示する。
Binding decisions / invariants:
- Browser から raw host path、backend-private absolute path、raw `WorkingDirectoryRequest`、internal runtime path を送らせない/返さない。
- pre-allocate 方式を優先する。単発 spawn request だけで暗黙 materialize する経路は後回し。
- v0 materializer は existing local Git detached worktree 方針を使う。remote clone/cache、dirty source inclusion は扱わない。
- Worker spawn は作成済み allocation id と任意の `relative_cwd` を選ぶ。
- `relative_cwd` は materialized workspace root からの相対 path とし、absolute path、`..` escape、symlink escape、存在しない/不正 directory を拒否する。
- Browser-facing status/detail/list は safe summary のみ。内部 path / cleanup target raw path は露出しない。
- cleanup policy と manual cleanup operation は v0 の範囲で明示し、cleanup diagnostics は typed/sanitized にする。
- Workspace-scoped Browser API は `/api/w/<workspace-id>/...` を使い、Runtime `/v1/...` API を Browser contract として広げない。
Requirements / acceptance criteria:
- `POST /api/w/<workspace-id>/working-directories` 相当で configured repository + selector から working directory を作成できる。
- `GET /api/w/<workspace-id>/working-directories` 相当で safe summary list を取得できる。
- 必要なら detail / cleanup endpoint を scoped API に追加し、safe summary / typed diagnostic を返す。
- Worker launch UI で working directory を選択でき、raw path ではなく safe id/selector 情報を送る。
- Worker spawn 時に選択 allocation が Runtime の materialized workspace に接続され、Worker cwd/scope は materialized worktree 配下になる。
- invalid repository / dirty unsupported / remote unsupported / invalid `relative_cwd` は typed diagnostic。
- Worker detail/list に working directory safe summary を表示する。
- frontend check/test、workspace-server tests、`yoi ticket doctor`、`nix build .#yoi` を通す。
Implementation latitude:
- Allocation store の所在/形式、API endpoint 名、UI placement、cleanup status 名、test helper は既存 workspace-server / worker-runtime / web style に合わせてよい。
- v0 では Runtime materializer の既存 allocation evidence を再利用し、Browser-managed allocation registry は必要最小限でよい。
- Worker launch UI は既存 New Worker form に working directory select / create link / refresh を追加する形でよい。
- `relative_cwd` は empty/`.` を workspace root と扱ってよい。
Escalate if:
- raw host path や internal runtime path を Browser contract に出さないと満たせない場合。
- remote clone/cache, dirty snapshot/apply, credential resolution, multi-repository mount, strong sandbox policy, or cross-Runtime allocation migration が必要になる場合。
- pre-allocate 方式でなく implicit one-shot materialization を主経路にする必要が出た場合。
Validation:
- `cd web/workspace && deno task check && deno task test`
- `cargo test -p yoi-workspace-server`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo check -p yoi`
- `git diff --check`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`Ticket acceptance に含まれるため実行する。実行不能なら理由を implementation report に明記する)
Current code map:
- Runtime working directory: `crates/worker-runtime/src/catalog.rs`, `working_directory.rs`, `runtime.rs`, `worker_backend.rs`, `http_server.rs`
- Workspace Backend host/API: `crates/workspace-server/src/server.rs`, `hosts.rs`, `repositories.rs`, `config.rs`
- Worker launch UI and API helpers: `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`, worker launch model/tests, workspace API helper, scoped route layout。
- Worker detail/console: scoped worker console route and runtime worker detail/list projections。
Critical risks / reviewer focus:
- Browser payload/response must not expose raw path or raw internal materialization request。
- `allocation_id` must not become ambient authority across mismatched workspace/runtime/repository contexts。
- `relative_cwd` validation must handle absolute paths, `..`, symlink escape, missing/non-directory paths。
- cleanup operation must not delete outside materialized allocation root。
- Worker spawn should use existing materialized allocation rather than creating hidden implicit workspaces as the primary flow。
- Runtime `/v1` remains internal/trusted; Browser uses scoped workspace API。
---
<!-- event: state_changed author: orchestrator at: 2026-07-07T13:41:43Z 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(00001KWY8EHEJ)`: blocking relation 0 件。
- `TicketOrchestrationPlanQuery(00001KWY8EHEJ)`: prior record 0 件。今回 accepted_plan を記録済み。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
- visible Pods / TicketDoctor / bounded code map を確認。
Acceptance basis:
- concrete missing decision / information は残っていない。
- Ticket の scope は Browser-managed pre-allocated working directory と Worker spawn 接続に閉じており、remote clone/cache, dirty source inclusion, raw path Browser contract, implicit one-shot materialization は非目標/後回しとして明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-07T13:42:50Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWY8EHEJ-browser-working-directories`
- Branch: `work/00001KWY8EHEJ-browser-working-directories`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KWY8EHEJ-browser-execws` を起動した。
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-07T14:23:27Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `684b19e8 feat: add browser working directories` on branch `work/00001KWY8EHEJ-browser-working-directories`.
- Worktree status was clean after commit.
- Changed files include:
- `crates/worker-runtime/src/{catalog.rs,working_directory.rs,http_server.rs,runtime.rs,worker_backend.rs}`
- `crates/workspace-server/src/{companion.rs,hosts.rs,server.rs}`
- `web/workspace/src/lib/workspace-sidebar/{WorkersNavSection.svelte,types.ts,worker-launch.ts,worker-launch.test.ts}`
- Implementation summary:
- Runtime-side preallocated working directory create/list/detail/cleanup and allocation binding at Worker spawn。
- `relative_cwd` validation rejects absolute paths, `..`, missing paths, files, and symlink escapes。
- scoped Browser API: `POST/GET /api/w/<workspace-id>/working-directories`, detail, and cleanup。
- Browser API uses configured `repository_id` + selector + policy, not raw paths/internal `WorkingDirectoryRequest`
- Browser responses expose safe summaries only。
- Worker spawn uses selected allocation id plus optional safe `relative_cwd`
- Worker list/detail projections include working directory safe summary。
- Workspace UI Worker launch flow can select existing allocations or create a new allocation before launch。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass66 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `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-07T14:36:39Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blocker:
1. Invalid `relative_cwd` is rejected internally, but the Browser-facing Worker spawn API does not return the required typed working directory diagnostic。
- Ticket acceptance requires invalid `relative_cwd` to be rejected as a typed diagnostic。
- Materializer has specific typed codes:
- `working_directory_relative_cwd_invalid`
- `working_directory_relative_cwd_unavailable`
- `working_directory_relative_cwd_escape_rejected`
- `crates/worker-runtime/src/worker_backend.rs` converts `WorkingDirectoryDiagnostic` to a plain string in `WorkerExecutionResult::rejected(...)`
- Workspace host/API then maps this to generic diagnostics/error codes, including generic embedded execution rejection and generic `workspace_worker_create_failed`
- Result: Browser caller submitting invalid `relative_cwd` gets a generic worker-create/runtime rejection rather than a typed `working_directory_relative_cwd_*` diagnostic。
- Browser/scoped API-level tests cover materializer bind validation but not the API rejection shape。
Required fix:
- Preserve/propagate `WorkingDirectoryDiagnostic.code` through Worker spawn, or prevalidate allocation + `relative_cwd` in the Workspace API and return that typed diagnostic directly。
- Add a scoped Browser API test for at least one invalid `relative_cwd` that asserts the typed `working_directory_relative_cwd_*` diagnostic is returned。
Non-blocking follow-ups:
- The implementation otherwise appears aligned with the pre-allocation design: scoped `/api/w/<workspace-id>/working-directories` routes exist, Browser create payload uses configured `repository_id`/selector/policy rather than raw `WorkingDirectoryRequest`, summaries avoid raw paths, UI explicitly creates/selects allocations, and cleanup is bounded to the allocation root。
Validation reviewed:
- Reviewer read Ticket item/thread, inspected `git show --stat --check 684b19e8` / `git show --name-only 684b19e8`, and reviewed Runtime, Workspace API/host, and frontend launch files。
- Reviewer did not rerun full validation; Orchestrator-provided validation was used as passing evidence。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-07T14:51:28Z -->
## Implementation report
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `9adb0fae fix: preserve working directory cwd diagnostics` on branch `work/00001KWY8EHEJ-browser-working-directories`.
- Worktree status was clean after commit.
- Fix summary:
- Browser Worker spawn now prevalidates selected working directory allocation + `relative_cwd` through the materializer before spawn。
- `WorkingDirectoryDiagnostic` is mapped directly into Browser API error diagnostics, preserving typed codes such as `working_directory_relative_cwd_invalid`, `working_directory_relative_cwd_unavailable`, and `working_directory_relative_cwd_escape_rejected`
- Added a scoped Browser API regression test asserting invalid `relative_cwd: "../escape"` returns typed `working_directory_relative_cwd_invalid` diagnostic。
- Existing scoped working directory routes, safe summaries, explicit UI allocation flow, and bounded cleanup behavior remain unchanged。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass
- Requesting follow-up external review against `684b19e8..9adb0fae` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-07T14:56:52Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Evidence reviewed in implementation worktree only:
- Ticket records: `.yoi/tickets/00001KWY8EHEJ/item.md`, `thread.md`, and orchestration artifact。
- Fix diff: `9adb0fae` stat/check and `684b19e8..9adb0fae` focused diff for `crates/workspace-server/src/server.rs`
- Relevant files: `workspace-server/src/server.rs`, `worker-runtime/src/working_directory.rs`, `worker_backend.rs`, scoped host/runtime API pieces, and worker launch UI/model/tests。
Findings:
- Prior blocker is resolved. Browser Worker spawn now pre-validates `working_directory.relative_cwd` through the working directory materializer before calling the runtime spawn path, and maps `WorkingDirectoryError` into API diagnostics via `working_directory_api_error`
- Invalid Browser spawn `relative_cwd` now returns typed diagnostics with the expected codes, covered by the added test asserting `working_directory_relative_cwd_invalid` appears both in diagnostics and message。
- Browser-facing spawn payload remains allocation-based (`allocation_id` + optional `relative_cwd`) rather than exposing raw `WorkingDirectoryRequest` or host paths. UI request construction sends allocation id and relative cwd only。
- Scoped create/list/detail/cleanup routes remain under `/api/w/:workspace_id/working-directories`; worker creation uses the scoped workspace API path from the Browser UI。
- Cleanup remains bounded to the allocation root: cleanup re-derives allocation root from the allocation id/root, canonicalizes workspace root, rejects escape, and removes only the recorded workspace root。
- Runtime/internal trusted path still performs canonical materialization/bind at Worker launch, while Browser gets a safer scoped allocation API。
- Reported validation was comprehensive and green; reviewer did not rerun tests as this was a read-only follow-up review。
Non-blocking follow-ups: none identified。
No merge-blocking issues found.
---
<!-- event: state_changed author: orchestrator at: 2026-07-07T14:58:18Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fix。
- Implementation branch `work/00001KWY8EHEJ-browser-working-directories` was merged into the orchestration branch with merge commit `b52986e5 merge: browser working directories`
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass
Implementation commits:
- `684b19e8 feat: add browser working directories`
- `9adb0fae fix: preserve working directory cwd diagnostics`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-07T14:58:35Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-07T14:58:35Z status: closed -->
## 完了
完了。
実装内容:
- Browser-managed working directory API を scoped Workspace API に追加した。
- `POST /api/w/<workspace-id>/working-directories`
- `GET /api/w/<workspace-id>/working-directories`
- detail / cleanup endpoint
- Browser create payload は configured `repository_id` / selector / policy を使い、raw host path / raw internal `WorkingDirectoryRequest` / internal runtime path を受け取らないようにした。
- Browser response は allocation id、repository id、selector/commit evidence、status、cleanup policy など safe summary のみを返すようにした。
- Worker launch UI で working directory を create/select できるようにした。
- Worker spawn は selected allocation id + optional `relative_cwd` を使い、Runtime materialized workspace に接続するようにした。
- `relative_cwd` は materialized root 相対として検証し、absolute path、`..` escape、symlink escape、nonexistent / non-directory を拒否するようにした。
- invalid `relative_cwd` は Browser-facing API で typed `working_directory_relative_cwd_*` diagnostic として返るようにした。
- Worker list/detail に working directory safe summary を含めた。
- cleanup は allocation root に bounded され、typed/sanitized diagnostic を返すようにした。
- Focused backend/runtime/web tests を追加した。
主な commit / merge:
- implementation: `684b19e8 feat: add browser working directories`
- review fix: `9adb0fae fix: preserve working directory cwd diagnostics`
- merge into orchestration: `b52986e5 merge: browser working directories`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cd web/workspace && deno task check`: pass0 errors / 0 warnings
- `cd web/workspace && deno task test`: pass17 tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main tests
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass36 lib tests + 5 main tests + doc tests
- `cargo check -p yoi`: pass
- `yoi ticket doctor`: ok
- `nix build .#yoi --no-link`: pass
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260708-091305-1","ticket_id":"00001KWZ5KERY","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWZ5KERY は incoming dependent Ticket 00001KX0DSMPT を持つが自身の blocker はなし。Browser/Backend launch の Profile discovery を Decodal ProfileSourceArchive に移行し、Runtime-local filesystem discovery を通常経路から外す。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWZ5KERY-decodal-profile-archive","worktree":"/home/hare/Projects/yoi/.worktree/00001KWZ5KERY-decodal-profile-archive","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Decodal Profile DSL / ProfileSourceArchive builder/verify/cache / archive-only SourceLoader / Runtime Browser launch integration / builtin Decodal profiles / compatibility fallback isolation / tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-08T09:13:05Z"}

View File

@ -0,0 +1,196 @@
---
title: 'Migrate Profiles to Decodal ProfileSourceArchive for Runtime launch'
state: 'closed'
created_at: '2026-07-07T20:51:35Z'
updated_at: '2026-07-08T10:35:38Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-08T09:11:22Z'
---
## 背景
Runtime の Browser/Backend 経由 Worker creation は、Workspace filesystem を profile/config discovery source として読まない境界にする必要がある。現状の Lua Profile 経路は `ProfileRuntimeWorkerFactory` / `resolve_runtime_profile_manifest` が Runtime-local path を `workspace_root` / `profile_base_dir` として扱い、そこから `.yoi/profiles.toml`、Lua Profile file、local require module、`.yoi/override.local.toml` を探索・読み取りしている。
Lua Profile は sandbox されているが、設定 DSL としては過剰であり、`require` / local module / FS discovery の境界が複雑になっている。Profile は Workspace/Profile source graph として Backend が正本を持ち、Runtime はその source graph を filesystem ではなく archive artifact として受け取り、決定的に評価できればよい。
本 Ticket では Lua Profile の通常経路を Decodal Profile DSL に移行し、Backend が selected Profile revision の source graph を tar archive (`ProfileSourceArchive`) として構築する。Runtime は archive を prefetch / verify し、Decodal `SourceLoader` を archive 内 source に限定して Profile を resolve する。
## 実装順序
1. 本 Ticket `00001KWZ5KERY`: Decodal Profile DSL、`ProfileSourceArchive`、Runtime archive prefetch/verify、FSなし profile resolution を実装する。
2. Ticket `00001KX0DSMPT`: Workspace settings API/pages を Decodal Profile source 編集向けに実装する。Profile source model / validation が本 Ticket で固まった後に進める。
3. Runtime-local artifact sync は別 Ticket とする。Plugin package、MCP executable、sandbox image など、Runtime-local 実体が必要なものだけを対象にする。
## 実装目的
- Lua Profile を Browser/Backend Worker launch の通常経路から外し、Decodal Profile DSL を使う。
- Backend は Workspace/Profile source の正本として Profile source graph を読み、tar 化した `ProfileSourceArchive` を作る。
- Runtime は Workspace filesystem / WorkingDirectory root から Profile discovery を行わず、prefetch 済み archive だけを読む。
- Decodal import は Backend への都度 fetch ではなく、archive manifest が許可する source graph 内で解決する。
- WorkingDirectory は Worker execution root/cwd としてのみ使う。
- Memory / Ticket / Objective のような更新頻度が高い Workspace product state は Runtime に同期せず、Backend API / capability / tool 経由でアクセスする。
- Plugin package、MCP executable、sandbox image など Runtime-local 実体が必要な artifact は Profile source archive と分離する。
## 実装内容
### 1. Decodal Profile DSL を導入する
Lua Profile の通常経路を置き換える Decodal profile schema を追加する。
実装すること:
- Decodal dependency を追加する。
- Worker manifest/config に必要な typed schema を Decodal materialization target として定義する。
- builtin role Profiles を Decodal source へ移行する。
- Profile selector / role metadata / model/provider refs / tool policy / memory/ticket/language policy / prompt/resource refs を Decodal から表現できるようにする。
- secret values、host absolute path、Runtime endpoint、socket/session path、runtime-local mount actual path は schema validation で拒否する。
- Lua Profile は compatibility-only path とし、Browser/Backend launch の通常経路では使わない。
### 2. `ProfileSourceArchive` tar format を定義する
Backend が Runtime に渡す profile source graph artifact を tar archive として定義する。
archive の例:
```text
manifest.json
sources/root.dcl
sources/modules/foo.dcl
sources/modules/bar.dcl
```
manifest に含める情報:
- archive kind / version。
- workspace id。
- profile selector。
- profile revision。
- root source key / root path。
- source entries: key, relative archive path, digest, size, content type。
- import map / import policy。
- builtin profile version / source provenance。
- archive digest。
禁止する情報:
- secret values。
- host absolute path。
- Runtime endpoint / token / socket path / session path。
- WorkingDirectory root path。
- raw Browser cwd / raw filesystem scope。
- Ticket / Objective / Memory contents。
- Plugin executable/package bytes。
### 3. Backend に archive builder を実装する
Workspace Backend が profile/role selector から `ProfileSourceArchive` を作る。
実装すること:
- workspace/project/builtin Decodal Profile registry discovery を Backend 側で行う。
- selected source と import closure を読み取る。
- import specifier を Backend 側で解決し、許可された source graph に閉じる。
- artifact size / file count / import depth / unsupported source / symlink escape / path escape を validation する。
- source digest と archive digest を計算する。
- archive manifest と tar bytes を作る。
- Browser launch options は profile/role candidates だけ返し、archive content / digest / Runtime internal identity は返さない。
### 4. Runtime に archive prefetch / verify / cache を実装する
Runtime Worker creation は `ProfileSourceArchiveRef` を受け取り、archive が無ければ Backend から prefetch する。
実装すること:
- `CreateWorkerRequest` または Backend-facing wrapper に `ProfileSourceArchiveRef` を追加する。
- Runtime は archive digest / manifest schema / source digests / total size / source paths を検証する。
- archive path は relative のみ許可し、absolute path / `..` / symlink escape を拒否する。
- same digest の archive は idempotent に cache / reuse できる。
- missing archive は Backend から fetch して repair できる。
- fetch failure / digest mismatch / manifest invalid / source missing / unsupported archive version は typed diagnostic にする。
### 5. Decodal `SourceLoader` を archive 内 source に限定する
Runtime は Decodal evaluator を持つが、SourceLoader は filesystem や Backend 都度 fetch を行わない。
実装すること:
- `ArchiveSourceLoader` を実装する。
- `SourceLoader::load(current_key, specifier)` は archive manifest / import map だけを参照する。
- import が manifest 許可外に出る場合は typed diagnostic にする。
- Decodal evaluation に max imports / max total bytes / max depth / timeout を設定する。
- resolved Worker manifest/config は既存 validation boundary を通す。
### 6. Worker creation を archive-resolved config に接続する
Runtime Worker creation は、通常経路で Runtime-local filesystem から Profile discovery しない。
実装すること:
- Backend は Worker launch 時に selected Profile revision に対応する `ProfileSourceArchiveRef` を Runtime に渡す。
- Runtime は archive を verify し、Decodal で resolved Worker manifest/config を構築する。
- WorkingDirectory は execution root/cwd/scope の計算にのみ使う。
- Runtime-local profile fallback は Browser/Backend launch の通常経路では使わない。
- Worker metadata / audit に archive id / digest / source graph digest summary を残す。
### 7. Runtime-local artifact sync を別境界に分離する
Profile source archive と Runtime-local artifact sync を混ぜない。
Runtime-local sync/check 対象:
- Plugin package / plugin binary。
- MCP server executable / service package。
- sandbox image / toolchain / runtime-local service。
- large prompt/resource asset のうち Runtime local file として必要なもの。
Runtime-local sync/check 対象ではないもの:
- Profile registry / Profile source archive 以外の Workspace product state。
- Memory / Ticket / Objective / Workspace metadata。
- Repository contents。
Profile から plugin enablement / package refs が出る場合、Runtime は package availability を別途 check し、missing / unsupported / digest mismatch を typed diagnostic にする。
### 8. compatibility fallback を通常経路から外す
既存 CLI / tests / builtin debugging のために Lua/runtime-local profile discovery fallback が残る場合は、compatibility-only として隔離する。
実装すること:
- Browser / Workspace Backend launch では fallback を使わない。
- fallback path は test name / diagnostic / code comment で compatibility-only と明示する。
- `profile_base_dir` / legacy `workspace_root` 呼称が残る場合、通常 launch 経路から参照されていないことを test で確認する。
## 受け入れ条件
- Decodal Profile DSL が Worker launch config を表現できる。
- builtin role Profiles が Decodal source として利用できる。
- `ProfileSourceArchive` tar format と manifest schema が実装されている。
- Backend が profile/role selector から `ProfileSourceArchive` を構築できる。
- Backend archive builder が import closure を解決し、archive path / symlink / size / depth / unsupported source を検証する。
- Runtime が `ProfileSourceArchiveRef` から archive を prefetch / verify / cache できる。
- Runtime `ArchiveSourceLoader` が archive manifest 内の source だけから Decodal import を解決する。
- Runtime Worker creation は通常経路で Workspace filesystem / profile base / WorkingDirectory root から Profile discovery を行わない。
- WorkingDirectory は Worker execution root/cwd としてのみ使われる。
- Browser-facing API に archive content、archive digest、Runtime store path、raw path、Runtime endpoint が露出しない。
- Memory / Ticket / Objective は sync 対象ではなく、Backend API / capability / tool 経由でアクセスする方針が code/docs/tests 上で崩れていない。
- Plugin/MCP/sandbox など Runtime-local artifact availability は `ProfileSourceArchive` とは別の check/sync 境界として扱われる。
- Missing profile、ambiguous selector、invalid Decodal source、missing import、import outside graph、artifact too large、archive digest mismatch、unsupported plugin package、missing runtime-local artifact は typed diagnostic になる。
- Focused tests が Backend archive build、Runtime archive verify、ArchiveSourceLoader import resolution、Worker create without Runtime FS discovery、WorkingDirectory launch success、Browser payload redaction、compatibility fallback isolation を確認する。
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo check -p yoi` が通る。
- `cd web/workspace && deno task check && deno task test` が通る。
- `yoi ticket doctor` が通る。
- `nix build .#yoi --no-link` が通る。
## 非目標
- Secret value synchronization。
- Ticket / Objective / Memory / repository contents / Worker catalog の Runtime 同期。
- Plugin package manager / signature policy の完成。
- Multi-tenant auth model の完成。
- Browser-facing Profile editor UI。これは `00001KX0DSMPT` で扱う。
- Runtime が Workspace filesystem を profile/config discovery source として読む経路の延命。
- WorkingDirectory materializer の新しい dirty snapshot / cache / remote clone policy。

View File

@ -0,0 +1,36 @@
完了。
実装内容:
- Decodal dependency/schema を導入し、builtin role Profiles を Decodal sources として追加した。
- `ProfileSourceArchive` tar format と `manifest.json` schema を実装した。
- Backend が profile/role selector から archive を構築し、import closure / source digest / archive digest / size/count/depth/path safety を検証するようにした。
- Runtime が `ProfileSourceArchiveRef` / config bundle から archive を prefetch / verify / cache / reuse できるようにした。
- Runtime `ArchiveSourceLoader` は archive-contained Decodal sources のみを解決し、undeclared import / filesystem fallback を拒否するようにした。
- Worker creation の通常 Browser/Backend path は archive-resolved Decodal config を使い、Runtime-local filesystem profile discovery を使わないようにした。
- Worker metadata/config bundle summary に archive id / source graph summary を残し、Browser-facing response には archive content / digest / store path / raw path を出さないようにした。
- Lua filesystem Profile path は compatibility/debug fallback として隔離した。
- Unknown Builtin / Named selectors と missing entrypoint は default fallback せず typed diagnostics で reject するようにした。
- Builtin `.dcdl` は real Backend-built `ProfileSourceArchive` + Decodal + `ProfileConfig` path で resolve できるように修正した。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `a823d414 feat: add decodal profile archives`
- review fix: `a2833dad fix: reject unknown profile archive selectors`
- merge into orchestration: `0334c572 merge: decodal profile archives`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass41 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass69 lib tests + 2 main 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
補足:
- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code/archive changes は merge 済み。

View File

@ -0,0 +1,381 @@
<!-- event: create author: "yoi ticket" at: 2026-07-07T20:51:35Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-08T09:03:55Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-08T09:03:55Z 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-08T09:11:22Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-08T09:12:55Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket は Decodal Profile DSL / `ProfileSourceArchive` / Backend archive builder / Runtime archive verify-cache / archive-only SourceLoader / Browser-Backend launch 経路からの Runtime-local FS profile discovery 排除について、binding invariants と acceptance criteria を具体的に列挙している。
- `00001KX0DSMPT` はこの Ticket に `depends_on` している incoming relation であり、この Ticket の blocker ではない。
- typed outgoing relation blocker は 0 件、OrchestrationPlan record は 0 件。
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
- Decodal API / dependency availability や既存 Lua profile compatibility の細部は bounded implementation investigation に閉じる。不足が設計境界変更に発展する場合は escalation 条件にする。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KWZ5KERY)`: incoming `00001KX0DSMPT depends_on 00001KWZ5KERY` あり。outgoing blocker なし。
- `TicketOrchestrationPlanQuery(00001KWZ5KERY)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- visible Pods: previous child Pods が idle で残っているが、`StopPod` 不使用方針のため停止せず、新規 unique Pod を使う。capacity blocker ではない。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/manifest/src/profile.rs`, `crates/worker/src/entrypoint.rs`, `crates/worker-runtime/src/{worker_backend.rs,runtime.rs,catalog.rs,config_bundle.rs,fs_store.rs,http_server.rs,main.rs}`, `crates/workspace-server/src/{hosts.rs,server.rs,companion.rs}`, builtin Lua profiles under `resources/profiles/*.lua`, profile discovery/setup code in `crates/tui` / `crates/worker`
IntentPacket:
Intent:
- Browser/Workspace Backend Worker launch の通常経路を Lua/runtime-local filesystem profile discovery から切り離し、Backend が Decodal Profile source graph を `ProfileSourceArchive` tar artifact として構築し、Runtime が archive を verify/cache して archive-contained Decodal sources だけで Worker config を resolve する。
Binding decisions / invariants:
- Runtime は通常 Browser/Backend launch 経路で Workspace filesystem / WorkingDirectory root / `profile_base_dir` から `.yoi/profiles.toml`、Lua file、Lua local module、`.yoi/override.local.toml` を探索しない。
- WorkingDirectory は Worker execution root/cwd/scope 計算にのみ使う。
- Profile source archive と Runtime-local artifact sync は別境界。Plugin/MCP/sandbox/image/package bytes は archive に入れない。
- Memory / Ticket / Objective / repository contents / Worker catalog は Runtime sync 対象ではない。
- Browser-facing API は archive content、archive digest、Runtime store path、raw path、Runtime endpoint/token/socket/session path を出さない。
- `ProfileSourceArchive` manifest/source paths は relative only。absolute path、`..`、symlink/path escape、unsupported source、size/count/depth limit excess を拒否する。
- Decodal import resolution は archive manifest / import map に閉じる。Runtime SourceLoader は filesystem/backend ad-hoc fetch をしない。
- Lua Profile は compatibility-only path として残す場合も Browser/Backend launch 通常経路では使わない。
Requirements / acceptance criteria:
- Decodal dependency/schema により Worker launch config が表現できる。
- builtin role Profiles が Decodal source として利用できる。
- `ProfileSourceArchive` tar format と manifest schema が実装される。
- Backend は profile/role selector から archive を構築し、import closure/digests/archive digest/limits/path safety を検証する。
- Runtime は `ProfileSourceArchiveRef` から archive を prefetch / verify / cache / reuse できる。
- Runtime `ArchiveSourceLoader` は archive-contained source だけから Decodal imports を解決する。
- Worker creation は通常経路で archive-resolved config を使い、Runtime-local FS profile discovery を使わない。
- Worker metadata/audit に archive id/digest/source graph summary が残る。
- Missing profile / ambiguous selector / invalid Decodal / missing import / outside graph / artifact too large / digest mismatch / unsupported plugin package / missing runtime-local artifact が typed diagnostic になる。
- Focused tests が Backend archive build、Runtime archive verify/cache、ArchiveSourceLoader、Worker create without Runtime FS discovery、WorkingDirectory launch success、Browser payload redaction、compat fallback isolation を確認する。
Implementation latitude:
- Decodal source file extension/module layout/schema names、archive manifest field names、cache directory layout、diagnostic enum location、test fixture organization は既存 style に合わせてよい。
- Existing `ConfigBundle` machineryを置き換えるか、archive-resolved config の Runtime-side cache/availability boundaryとして再利用するかは、invariant を守る範囲で選んでよい。
- Compatibility-only Lua path は CLI/debug/test 用に残してよいが、通常 Browser/Backend launch path から分離されていることを tests/comments/diagnostics で明示する。
- Runtime-local artifact availability は full package manager 実装でなく typed check/sync boundary の分離を示す最小実装でよい。
Escalate if:
- Decodal crate/API が Worker config materialization に必要な capabilities を持たない、または semantic redesign が必要で Ticket の範囲を超える場合。
- Browser/Backend launch を raw path/runtime-local profile discovery に戻さないと成立しない場合。
- Secret synchronization、Plugin package manager/signature policy、multi-tenant auth、Profile editor UI、Runtime-local artifact sync full implementation が必要になる場合。
- Archive digest/content/runtime store path を Browser-facing API に出す必要が出た場合。
Validation:
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `cd web/workspace && deno task check && deno task test`
- `git diff --check`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`Ticket acceptance に含まれるため実行する。実行不能なら理由を implementation report に明記する)
Current code map:
- Profile discovery/resolution: `crates/manifest/src/profile.rs`, `crates/worker/src/entrypoint.rs` (`resolve_runtime_profile_manifest`)。
- Runtime Worker launch: `crates/worker-runtime/src/worker_backend.rs` (`ProfileRuntimeWorkerFactory`), `runtime.rs`, `catalog.rs`, `config_bundle.rs`, `fs_store.rs`, `http_server.rs`, `main.rs`
- Backend/Runtime launch bridge: `crates/workspace-server/src/hosts.rs`, `server.rs`, `companion.rs`
- Builtin profiles: `resources/profiles/*.lua` to migrate/create Decodal equivalents。
- CLI/profile compatibility surfaces: `crates/tui/src/spawn.rs`, `crates/tui/src/setup_model.rs`, `crates/worker/src/spawn/tool.rs`
Critical risks / reviewer focus:
- Runtime Worker creation path must not silently keep reading `profile_base_dir` / WorkingDirectory / workspace filesystem in Browser/Backend launch。
- Archive verification must be path-safe, digest-checked, bounded, and deterministic。
- Decodal import graph must be closed by manifest/import map; no filesystem/backend dynamic import fallback。
- Browser-facing responses must not leak archive content/digest/runtime store/raw paths/endpoints/secrets。
- Builtin role Decodal sources must cover existing role behavior enough for Browser/Backend launch。
- Compatibility Lua fallback must be isolated and not accidentally used by ordinary Browser/Backend launch。
- Runtime-local artifact availability checks must stay separated from Profile source archive。
---
<!-- event: state_changed author: orchestrator at: 2026-07-08T09:13:16Z 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(00001KWZ5KERY)`: incoming dependent `00001KX0DSMPT depends_on 00001KWZ5KERY` あり。this Ticket の blocking outgoing relation は 0 件。
- `TicketOrchestrationPlanQuery(00001KWZ5KERY)`: prior record 0 件。今回 accepted_plan を記録済み。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
- visible Pods / TicketDoctor / bounded code map を確認。
Acceptance basis:
- concrete missing decision / information は残っていない。
- Ticket の scope は Decodal Profile DSL / ProfileSourceArchive / Runtime archive-only profile resolution / compatibility fallback isolation に明確化されており、Profile editor UI、secret sync、plugin package manager、runtime-local artifact sync full implementation は非目標として明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T09:14:24Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWZ5KERY-decodal-profile-archive`
- Branch: `work/00001KWZ5KERY-decodal-profile-archive`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KWZ5KERY-decodal-profile` を起動した。
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T09:59:31Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `a823d414 feat: add decodal profile archives` on branch `work/00001KWZ5KERY-decodal-profile-archive`.
- Worktree status was clean after commit.
- Changed files include:
- `Cargo.toml`, `Cargo.lock`, `package.nix`
- `crates/manifest/src/profile.rs`
- `crates/worker-runtime/src/{profile_archive.rs,config_bundle.rs,worker_backend.rs,runtime.rs,catalog.rs,http_server.rs,main.rs}`
- `crates/worker/src/entrypoint.rs`
- `crates/workspace-server/src/{hosts.rs,companion.rs,server.rs}`
- builtin Decodal sources `resources/profiles/*.dcdl`
- Implementation summary:
- Added Decodal-backed `ProfileSourceArchive` tar format with `manifest.json`, source graph summary, digest verification, path confinement, limits, unsupported source/symlink/path escape rejection。
- Added archive-only Decodal `ArchiveSourceLoader` using manifest import map only。
- Added builtin Decodal role Profiles。
- Backend builds profile source archives for normal Workspace/Browser and Companion launches。
- Runtime config bundle validation verifies/stores archive content and passes verified bundle into Worker execution。
- `ProfileRuntimeWorkerFactory` uses archive-resolved Decodal config for normal Runtime launch; Runtime-local Lua/filesystem profile discovery remains compatibility/debug fallback when no archive is supplied。
- Browser/runtime summaries expose bounded source graph summary only, not archive content/digest/store paths。
- Nix `cargoHash` updated for new Rust dependencies。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass40 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass67 lib tests + 2 main 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-08T10:07:07Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blockers:
1. Builtin Decodal profiles が現在の schema で実際には解決できず、normal Browser/Backend launch が archive path で失敗する。
- Evidence:
- builtin Decodal files に `tool_enabled` が含まれる(例: `resources/profiles/default.dcdl`, `coder.dcdl`, `reviewer.dcdl`)。
- archive resolution は Decodal を JSON に materialize してすぐ `ProfileConfig` に deserialize する。
- `ProfileConfig``#[serde(deny_unknown_fields)]` で、`feature`, `permissions` などはあるが `tool_enabled` field がない。
- Impact:
- acceptance criterion「builtin role Profiles が Decodal source として利用できる」を満たしていない。
- Backend-built archives が normal workspace/Companion launch に使われるなら、unknown-field error で Worker creation 前に失敗する。
- Required fix:
- Decodal profile schema で既存 typed fields を使って tool policy を表現するか、`tool_enabled` 相当を typed mapping/field として受け入れて Worker manifest/config に変換する。
- embedded builtin `.dcdl` それぞれを real `ProfileSourceArchive` + Decodal + `ProfileConfig` path で resolve する focused test を追加する。
2. Missing/unknown profile selectors が typed diagnostic にならず、silent default fallback する。
- Evidence:
- Runtime archive resolution が requested selector 欠落時に `"default"` へ fallback している。
- Backend archive construction が selected label を `embedded_profile_path(profile)` に入れ、unknown `Builtin` / `Named` profile を `profiles/default.dcdl` に map している。
- Impact:
- missing profile / ambiguous selector が typed diagnostic になるという acceptance criterion に違反。
- invalid/custom profile request が default profile Worker として成功し得るため、Browser/Backend launch の correctness / authority boundary 問題。
- Required fix:
- unknown profile selector は archive construction 前または archive resolution 中に typed diagnostic で拒否する。
- explicit RuntimeDefault/default 以外の default fallback を削除する。
- unknown `Named` / unknown `Builtin` selector の tests を追加する。
Validation / inspection performed:
- Ticket records と IntentPacket / acceptance criteria を確認。
- implementation worktree 内で implementation commit/diff targets を read-only inspection。
- archive format/verification, config bundle storage, Runtime launch, Backend archive construction, Browser-facing summaries, builtin Decodal profiles を確認。
- broad build/test matrix は Orchestrator-reported validation pass を参照し、Reviewer は変更を行っていない。
Non-blocking follow-ups:
- blockers 修正後、embedded Decodal profiles を使う actual Browser/Backend default launch bundle path の integration-style unit test を追加するとよい。
- supported profile registry boundary をより明確にするとよい。現状 Backend archive construction は embedded builtins hardcode で、Ticket は workspace/project/builtin profile registry discovery を記述している。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T10:23:11Z -->
## Implementation report
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `a2833dad fix: reject unknown profile archive selectors` on branch `work/00001KWZ5KERY-decodal-profile-archive`.
- Worktree status was clean after commit.
- Fix summary:
- Embedded Decodal profiles no longer use unsupported `tool_enabled`; they now use existing typed `ProfileConfig` fields such as `feature` plus existing model/session/engine/memory/web fields as applicable。
- Archive selector fallback to `default` was removed; missing selector now returns `ProfileArchiveError::MissingEntrypoint`
- Backend embedded archive construction rejects unknown `Builtin` and unknown `Named` selectors instead of mapping them to default。
- Added focused tests proving every embedded builtin `.dcdl` resolves through real Backend-built `ProfileSourceArchive` + Decodal + `ProfileConfig` path。
- Added tests for unknown Builtin/Named selector rejection and no default fallback for missing selectors。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass41 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass69 lib tests + 2 main 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
- Requesting follow-up external review against `a823d414..a2833dad` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-08T10:33:16Z status: approve -->
## Review: approve
External follow-up review result: approve
Result: approve
Review evidence:
- Prior blocker 1 is fixed:
- Builtin Decodal profiles now resolve through `default_embedded_config_bundle(...) -> ProfileSourceArchive -> verify() -> resolve_profile(...) -> ProfileConfig` rather than bypassing archive resolution。
- `resources/profiles/*.dcdl` no longer use unsupported `tool_enabled`; they use supported `feature = { ... }` fields and are covered by the added embedded-profile resolution test。
- Prior blocker 2 is fixed:
- `VerifiedProfileSourceArchive::resolve_profile` no longer falls back to `default` for missing selectors。
- Unknown `Builtin` and `Named` selectors are rejected before bundle construction in `embedded_profile_path(...)`, with typed runtime diagnostics from embedded/remote launch paths。
- Explicit runtime default remains valid via default/builtin-default archive entrypoints。
- Browser/Backend launch path:
- Browser only selects Backend-published profile candidates。
- Workspace Backend builds/syncs a config bundle containing the archive before `CreateWorkerRequest`
- Runtime `create_worker` passes the stored config bundle into the execution backend; `ProfileRuntimeWorkerFactory` only uses the Lua/filesystem fallback when no config bundle is present, so ordinary Browser/Backend launch does not read Runtime-local profile discovery。
- Archive invariants:
- Archive build/verify enforces relative safe tar paths, source counts/sizes/total bytes, source digests, archive digest, supported source kind, and declared import-map lookup。
- Undeclared imports are rejected by `ArchiveSourceLoader`
- Browser-facing exposure:
- Web launch types expose only profile candidates。
- Config bundle summaries expose source graph summary rather than archive content or archive digest; worker summaries retain only profile selector/config-bundle ref metadata and do not expose runtime store paths/endpoints/secrets。
- Compatibility:
- Lua/filesystem fallback remains isolated to direct/debug paths without a config bundle。
- Dependency/package changes look sound, and the provided validation set includes Rust tests/checks, Deno checks/tests, ticket doctor, and `nix build .#yoi --no-link`
No merge blockers found.
---
<!-- event: state_changed author: orchestrator at: 2026-07-08T10:35:19Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fix。
- Implementation branch `work/00001KWZ5KERY-decodal-profile-archive` was merged into the orchestration branch with merge commit `0334c572 merge: decodal profile archives`
- Ticket-record conflicts during merge were resolved by preserving Orchestrator-side Ticket thread/item records; product/code/archive changes were merged。
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass41 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass69 lib tests + 2 main 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
Implementation commits:
- `a823d414 feat: add decodal profile archives`
- `a2833dad fix: reject unknown profile archive selectors`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-08T10:35:38Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-08T10:35:38Z status: closed -->
## 完了
完了。
実装内容:
- Decodal dependency/schema を導入し、builtin role Profiles を Decodal sources として追加した。
- `ProfileSourceArchive` tar format と `manifest.json` schema を実装した。
- Backend が profile/role selector から archive を構築し、import closure / source digest / archive digest / size/count/depth/path safety を検証するようにした。
- Runtime が `ProfileSourceArchiveRef` / config bundle から archive を prefetch / verify / cache / reuse できるようにした。
- Runtime `ArchiveSourceLoader` は archive-contained Decodal sources のみを解決し、undeclared import / filesystem fallback を拒否するようにした。
- Worker creation の通常 Browser/Backend path は archive-resolved Decodal config を使い、Runtime-local filesystem profile discovery を使わないようにした。
- Worker metadata/config bundle summary に archive id / source graph summary を残し、Browser-facing response には archive content / digest / store path / raw path を出さないようにした。
- Lua filesystem Profile path は compatibility/debug fallback として隔離した。
- Unknown Builtin / Named selectors と missing entrypoint は default fallback せず typed diagnostics で reject するようにした。
- Builtin `.dcdl` は real Backend-built `ProfileSourceArchive` + Decodal + `ProfileConfig` path で resolve できるように修正した。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `a823d414 feat: add decodal profile archives`
- review fix: `a2833dad fix: reject unknown profile archive selectors`
- merge into orchestration: `0334c572 merge: decodal profile archives`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass41 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass69 lib tests + 2 main 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
補足:
- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code/archive changes は merge 済み。
---

View File

@ -0,0 +1,3 @@
{"id":"orch-plan-20260708-104512-1","ticket_id":"00001KX0DSMPT","kind":"after","related_ticket":"00001KX0G06VA","note":"This Ticket is queued while `00001KX0G06VA` is inprogress because both touch Workspace/Backend profile/resource launch surfaces (`workspace-server` settings/API/hosts/server and profile archive/resource fetch integration). Sequence after the active resource-fetch implementation to avoid conflicting public API and ProfileSourceArchive authority-boundary changes.","author":"orchestrator","at":"2026-07-08T10:45:12Z"}
{"id":"orch-plan-20260708-104530-2","ticket_id":"00001KX0DSMPT","kind":"waiting_capacity_note","note":"Queue review after Dashboard authorization. The declared dependency `00001KWZ5KERY` is closed, so that blocker is resolved. However `00001KX0G06VA` is currently inprogress on branch `work/00001KX0G06VA-resource-fetch-api` and changes the same ProfileSourceArchive/resource/workspace-server launch API surfaces this Ticket needs to build on. Leave this Ticket queued until the active resource-fetch branch is reviewed/merged or its API shape is otherwise decided; then re-route/start from the updated orchestration branch.","author":"orchestrator","at":"2026-07-08T10:45:30Z"}
{"id":"orch-plan-20260708-115622-3","ticket_id":"00001KX0DSMPT","kind":"accepted_plan","accepted_plan":{"summary":"`00001KWZ5KERY` と `00001KX0G06VA` が closed/merged になり、previous waiting reason は解除された。Workspace/Profile editing API/pages を updated orchestration branch から実装開始する。","branch":"work/00001KX0DSMPT-workspace-profile-settings","worktree":"/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Workspace/Profile settings scoped APIs、safe Profile registry/source read-write model、settings UI pages、Backend discovery invalidation、launch candidate integration、diagnostic/redaction tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / ProfileSourceArchive/resource-fetch authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-08T11:56:22Z"}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KX0DSMPT",
"kind": "depends_on",
"target": "00001KWZ5KERY",
"note": "Profile settings UI/API should target the Decodal Profile source model and ProfileSourceArchive boundary implemented first.",
"author": "yoi ticket",
"at": "2026-07-08T09:01:17Z"
}
]
}

View File

@ -0,0 +1,131 @@
---
title: 'Add Workspace settings API and pages for Workspace/Profile editing'
state: 'closed'
created_at: '2026-07-08T08:34:01Z'
updated_at: '2026-07-08T13:20:27Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-08T10:44:13Z'
---
## 背景
Workspace Backend は Browser/product Workspace の正本側であり、現状の local backend では Workspace 情報や Profile source を filesystem / project records から読んでいる。一方、Browser には Workspace 情報や Profiles を確認・編集するための明示的な API / 操作ページが十分にない。
Runtime は Workspace filesystem を直接読まない方向にするため、Workspace metadata、Profile registry、Decodal Profile source、override snapshot などの Workspace-derived config は Backend が管理・編集・検証し、Frontend は Backend API 経由で操作する必要がある。
本 Ticket は、Runtime 連携ではなく、Backend <-> Front の Workspace/Profile 管理面を作る実装 slice とする。Decodal Profile source model と `ProfileSourceArchive` の境界は `00001KWZ5KERY` で先に固め、本 Ticket はその後に UI/API 操作面を実装する。
## 実装順序
- depends_on: `00001KWZ5KERY` — Decodal Profile DSL と `ProfileSourceArchive` の source model / validation / import policy を先に実装する。
- then: 本 Ticket — Backend <-> Front の settings API と操作ページを、その source model に合わせて実装する。
## 実装目的
- Browser から Workspace 情報を確認・編集できる。
- Browser から Workspace/Profile source を確認・編集できる。
- Backend API が Workspace/Profile editing の authority になり、Frontend は filesystem path を直接扱わない。
- Profile editing 後に、Backend 側の Profile discovery / validation / launch options に反映される。
- Secret value、Runtime endpoint、host absolute path、runtime-local store path を Browser-facing API に出さない。
## 実装内容
### 1. Workspace settings API を追加する
`/api/w/<workspace-id>/settings` 配下に、Workspace metadata と Profile settings を扱う typed API を追加する。
v0 で扱う Workspace metadata:
- display name。
- workspace id / read-only identity summary。
- repository registry summary / read-only configured repositories。
- Backend local source summary。host absolute path は出さず、safe label / source kind / status だけを出す。
v0 で扱う Profile metadata:
- discovered profile registry entries。
- builtin profile entries。
- workspace/project profile entries。
- selected source kind / profile selector / label / role metadata。
- validation diagnostics。
### 2. Profile source read API を追加する
Frontend が Profiles を編集するため、Backend が safe Profile source model を返す。
実装すること:
- workspace/project `profiles.toml` 相当の registry を structured model として返す。
- Decodal Profile source を safe id で参照して読み出せる。
- imported source は Profile artifact に紐づく bounded artifact として返す。
- Browser-facing response は raw absolute path ではなく `profile_source_id` / `artifact_id` / safe display path を使う。
- artifact size / unsupported source / symlink escape / path escape は typed diagnostic にする。
### 3. Profile source write API を追加する
Browser から Profile registry / Decodal Profile source を更新できる typed mutation API を追加する。
実装すること:
- registry entry の create / update / delete。
- Decodal Profile source の create / update / delete。
- imported source の create / update / delete は v0 で必要なら bounded module root 内だけ許可する。
- write 前に syntax / schema / selector uniqueness / path safety を検証する。
- write 後に Profile discovery を再実行し、diagnostics と effective profile list を返す。
- concurrent update には revision / etag / updated_at 相当の optimistic check を使う。
### 4. Frontend の Workspace settings pages を追加する
既存 SvelteKit workspace-scoped route 配下に settings pages を追加・拡張する。
想定 route:
- `/w/<workspace-id>/settings` Workspace overview settings。
- `/w/<workspace-id>/settings/profiles` Profile list / validation diagnostics。
- `/w/<workspace-id>/settings/profiles/<profile-source-id>` Profile source editor。
UI 要件:
- Workspace display name を編集できる。
- Profile list を source kind / selector / label / diagnostics 付きで見られる。
- Profile registry を編集できる。
- Decodal Profile source は v0 では formatter / syntax highlighter なしの plain text textarea/editor で編集できる。
- 保存前後の validation diagnostics を表示する。
- raw absolute path、Runtime endpoint、secret value を表示しない。
- Browser WorkingDirectory / Worker launch の profile candidates と同じ Backend discovery 結果を見られる。
### 5. Change notification / invalidation を追加する
Profile source が更新されたとき、Backend 内の derived Profile state を invalidation する。
実装すること:
- Profile discovery cache がある場合は更新後に破棄・再構築する。
- Worker launch options API が更新後の Profile candidates を返す。
- 将来の ProfileSourceArchive builder が、更新後の source snapshot を使える状態にする。
- Runtime への直接 sync は本 Ticket の非目標とし、必要なら後続 Ticket に委譲する。
## 受け入れ条件
- Workspace-scoped Browser API で Workspace metadata を read/update できる。
- Workspace-scoped Browser API で Profile registry / Profile artifact を read/update できる。
- Frontend に Workspace settings page と Profiles settings page が追加されている。
- Profile source update 後、Backend の Profile discovery / launch options に変更が反映される。
- Profile selector duplicate、invalid registry schema、invalid Decodal syntax、path escape、symlink escape、artifact too large、concurrent update conflict は typed diagnostic になる。
- Browser-facing API / UI に host absolute path、secret value、Runtime endpoint、socket/session path、runtime-local store path が露出しない。
- Browser WorkingDirectory / Worker launch の profile candidates と settings page の profile list が同じ Backend discovery 結果を使う。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo check -p yoi` が通る。
- `cd web/workspace && deno task check && deno task test` が通る。
- `yoi ticket doctor` が通る。
- `nix build .#yoi --no-link` が通る。
## 非目標
- Runtime が ProfileSourceArchive を prefetch/verify して Decodal Profile を resolve する経路の実装。
- Runtime-local plugin / MCP / sandbox artifact sync。
- Secret value editor。
- Multi-user auth / RBAC。
- Monaco 等の heavyweight code editor 導入。
- Ticket / Objective / Memory の編集画面統合。

View File

@ -0,0 +1,33 @@
完了。
実装内容:
- Workspace/Profile settings scoped API を追加した。
- Workspace metadata read/update と safe identity/source summaries を扱う Settings API を追加した。
- Profile registry/source read/update API を追加し、create/update/delete と revision/etag-style optimistic checks を実装した。
- Backend profile settings model に safe source ids/display paths、Decodal/ProfileSourceArchive validation、selector uniqueness、path/symlink escape protection、source size checks、typed diagnostics を追加した。
- Profile updates 後に Backend profile discovery / validation / launch options を再構築し、Worker launch profile candidates と Settings profile list が同じ discovery result を使うようにした。
- invalid project profile は relevant profile summary に diagnostics を付与し、Worker launch candidates から除外し、candidate validation でも reject するようにした。
- Browser-facing responses/UI から host absolute paths、secret values、Runtime endpoints/tokens、socket/session paths、runtime-local store paths、archive content/digest、raw resource handles、backend-private paths を出さないようにした。
- Settings UI に Workspace overview と Profile list/source editor surface を追加した。
- Focused backend/web tests を追加/強化した。
主な commit / merge:
- implementation: `3eefd333 feat: add workspace profile settings`
- review fix: `0c2ca1ea fix: harden profile settings validation`
- merge into orchestration: `0bf3638c merge: workspace profile settings`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p yoi-workspace-server`: pass91 lib tests + 2 main 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
補足:
- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。

View File

@ -0,0 +1,381 @@
<!-- event: create author: "yoi ticket" at: 2026-07-08T08:34:01Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-08T10:40:06Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-08T10:40:06Z 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-08T10:44:13Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-08T10:45:48Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Declared dependency `00001KWZ5KERY` is now closed, so the original Decodal/ProfileSourceArchive prerequisite is satisfied。
- However `00001KX0G06VA` is currently `inprogress` on branch `work/00001KX0G06VA-resource-fetch-api`
- This Tickets Workspace/Profile editing API and pages need to build on the same profile/resource/workspace-server launch surfaces that `00001KX0G06VA` is actively changingProfileSourceArchive resource handling, workspace-server settings/API/hosts/server, Browser-facing profile launch configuration
- Starting this Ticket now would create a high-conflict parallel branch and risks designing Profile editing against the wrong resource-fetch/public API boundary。
- 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(00001KX0DSMPT)`: depends_on `00001KWZ5KERY`; target is closed。
- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior record 0 件だったため、今回 `after 00001KX0G06VA` と waiting-capacity note を記録。
- `TicketList`: queued はこの Ticket 1件、inprogress は `00001KX0G06VA` 1件。
- Orchestrator worktree git status: clean on `orchestration`
Next action:
- `00001KX0G06VA` の implementation/review/merge outcome を待つ。
- resource-fetch API shape が merge されたら、この Ticket を再 routing し、updated orchestration branch から start する。
Escalate if:
- 人間が `00001KX0G06VA` を中断してこの Ticket を先に実装する、または両者を同一 combined worktree で統合実装する方針に切り替えたい場合。
---
<!-- event: decision author: orchestrator at: 2026-07-08T11:56:03Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Previous waiting reason is resolved: `00001KX0G06VA` is now merged/validated/closed, so the ProfileSourceArchive resource-fetch API shape this Ticket depends on is stable enough to build against。
- Declared dependency `00001KWZ5KERY` is also closed, so Decodal Profile source model / ProfileSourceArchive boundary is available。
- Ticket body gives concrete Workspace/Profile settings API, read/write source model, UI routes, diagnostics, redaction invariants, and validation criteria。
- typed relation blocker is resolved (`depends_on 00001KWZ5KERY` target closed); no active inprogress Ticket remains。
- The user requested sequential consumption once no blocker remains, and the Dashboard queue authorization is already present。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KX0DSMPT)`: outgoing `depends_on 00001KWZ5KERY`; target is closed。
- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior `after 00001KX0G06VA` / waiting note; `00001KX0G06VA` is now closed with merge commit `01f94b75` and close commit `a645ee5b`
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: this Ticket 1件。inprogress は 0 件。
IntentPacket:
Intent:
- Workspace-scoped Browser API and pages for Workspace metadata and Profile registry/source editing, using Backend as the authority and the Decodal/ProfileSourceArchive model already merged。
- Frontend should edit Profiles through safe Backend APIs, not filesystem paths, and Worker launch profile candidates should reflect updated Backend discovery/validation state。
Binding decisions / invariants:
- Workspace/Profile editing APIs live under `/api/w/<workspace-id>/settings...` or consistent scoped Workspace API paths。
- Browser-facing API/UI must not expose host absolute paths, secret values, Runtime endpoints/tokens, socket/session paths, runtime-local store paths, archive content/digest, or raw resource handles。
- Profile source references use safe ids/artifact ids/display paths rather than raw absolute paths。
- Writes must validate syntax/schema/selector uniqueness/path safety before commit and return typed diagnostics。
- Decodal/ProfileSourceArchive source model from `00001KWZ5KERY` and resource-fetch boundary from `00001KX0G06VA` are authority inputs; do not reintroduce Runtime filesystem discovery。
- Runtime direct sync is out of scope; Backend discovery/launch options invalidation is in scope。
Requirements / acceptance criteria:
- Workspace metadata read/update API works for display name and safe identity/source summaries。
- Profile registry/source read/update APIs support create/update/delete with revision/etag-style optimistic check。
- Settings UI has Workspace overview and Profile list/source editor routes under `/w/<workspace-id>/settings...`
- Profile updates re-run Backend discovery/validation and update Worker launch profile candidates。
- Duplicate selector, invalid registry schema, invalid Decodal syntax, path/symlink escape, artifact too large, and concurrent update conflict return typed diagnostics。
- Browser WorkingDirectory / Worker launch profile candidates and settings Profile list share the same Backend discovery result。
Implementation latitude:
- Endpoint naming, module boundaries, revision token shape, source/artifact id representation, UI component split, and editor implementation can follow existing workspace-server / Svelte style。
- v0 plain textarea/editor is acceptable; no heavyweight editor required。
- Imported source write support can be limited to bounded module root or deferred with typed diagnostics if not needed for core v0。
Escalate if:
- Secret value editor, multi-user auth/RBAC, Runtime direct sync, Plugin/MCP/sandbox artifact sync, Ticket/Objective/Memory editor integration, or a new Profile source semantic model beyond Decodal/ProfileSourceArchive is required。
Validation:
- `cargo test -p yoi-workspace-server`
- `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:
- Workspace/profile settings backend: `crates/workspace-server/src/server.rs`, `hosts.rs`, `config.rs`, `resource_broker.rs`, Decodal/archive helpers。
- Profile archive/config: `crates/worker-runtime/src/profile_archive.rs`, `config_bundle.rs` for source model reference。
- Frontend settings: `web/workspace/src/routes/w/[workspaceId]/settings/**`, `web/workspace/src/lib/workspace-settings/**`, worker launch profile candidate helpers。
Critical risks / reviewer focus:
- No raw paths/secrets/runtime internals or resource handles leak to Browser。
- Profile editing updates the same Backend discovery/launch options used by Worker launch。
- Optimistic concurrency and path/symlink escape checks fail closed。
- Decodal validation is real and not only superficial string checks。
- Existing Runtime resource fetch / ProfileSourceArchive authority boundaries remain intact。
---
<!-- event: state_changed author: orchestrator at: 2026-07-08T11:56:36Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
## State changed
Queued acceptance recorded after previous waiting reason was resolved.
Checked context:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KX0DSMPT)`: depends_on `00001KWZ5KERY`; target is closed。
- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior `after 00001KX0G06VA` / waiting note を確認。`00001KX0G06VA` is now closed / merged。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
Acceptance basis:
- Previous blocker/waiting reason is resolved。
- concrete missing decision / information は残っていない。
- Ticket scope is Workspace/Profile settings API and pages using already-merged Decodal/ProfileSourceArchive and resource-fetch boundaries。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T11:58:14Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings`
- Branch: `work/00001KX0DSMPT-workspace-profile-settings`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KX0DSMPT-profile-settings` を起動した。
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T12:03:23Z -->
## Implementation report
Implementation routing recovery note:
- Initial Coder Pod `yoi-coder-00001KX0DSMPT-profile-settings` failed its first turn with provider `context_length_exceeded` before producing implementation output。
- Ticket remains `inprogress`; worktree/branch already exist and are clean at creation baseline。
- Recovery plan: avoid `StopPod` and retry with a fresh minimal Coder task that reads the Ticket records from the worktree instead of embedding the full IntentPacket in the prompt. If scope registry prevents a fresh Pod, fallback is to use the existing Pod with a much smaller instruction or ask for human cleanup/authority。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T12:40:07Z -->
## Implementation report
Implementation progress report:
- Recovery Coder Pod `yoi-coder2-00001KX0DSMPT-profile-settings` completed implementation and committed `3eefd333 feat: add workspace profile settings` on branch `work/00001KX0DSMPT-workspace-profile-settings`.
- Worktree status was clean after commit。
- Changed files include:
- `crates/workspace-server/src/profile_settings.rs` (new)
- `crates/workspace-server/src/{server.rs,hosts.rs,companion.rs,lib.rs}`
- `web/workspace/src/lib/workspace-settings/{profile-api.ts,profile-types.ts,model.ts}`
- `web/workspace/src/lib/workspace-api/http.ts`
- `web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte`
- child implementation also committed `.yoi/tickets/00001KX0DSMPT/{item.md,thread.md}` changes; Orchestrator will preserve authoritative Ticket thread/item records during integration if conflicts occur。
- Implementation summary:
- Added scoped Workspace/Profile settings API for workspace metadata and profile registry/source read/write。
- Added Backend profile settings model with safe source ids/display paths, revision checks, Decodal validation, selector uniqueness checks, path/symlink escape protection, and typed diagnostics。
- Profile updates rebuild Backend profile discovery/launch options so Worker launch profile candidates and settings profile list use the same discovery result。
- Extended settings UI with Workspace overview and Profiles list/editor surface using scoped APIs and safe summaries。
- Browser-facing responses avoid raw host paths, runtime internals, archive digests/content, and resource handles。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p yoi-workspace-server`: pass80 lib tests + 2 main 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-08T12:49:09Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blockers:
1. Profile mutation validation failures are not returned as typed diagnostics。
- scoped settings handlers call `profile_settings::*` with `?` directly (`server.rs:881-945`).
- `ApiError::from(Error)` initializes `diagnostics: Vec::new()` (`server.rs:3355-3360`).
- profile errors such as `profile_selector_duplicate`, `profile_source_syntax_invalid`, `profile_source_path_escape`, `profile_registry_revision_conflict` fall through to `BAD_GATEWAY` (`server.rs:3403-3414`) with an empty diagnostics array (`server.rs:3420-3423`).
- Recorded requirement says duplicate selector, invalid registry/schema/Decodal, path/symlink escape, artifact too large, and concurrency conflicts must return typed diagnostics.
2. Worker launch profile candidates can include invalid project profiles。
- `load_profile_settings` attaches Decodal/source validation errors from `validate_project_profiles` only to top-level `diagnostics` (`profile_settings.rs:327`, `663-707`).
- each project `WorkspaceProfileSummary` only gets `source_summary` diagnostics (`profile_settings.rs:304-323`).
- `worker_profile_candidates_for_root` includes project profiles when `profile.diagnostics.is_empty()` (`server.rs:2989-2992`).
- `is_profile_candidate` accepts any discovered project profile id (`profile_settings.rs:629-635`).
- Result: registry entry with invalid Decodal source can still appear in launch options and be accepted as a candidate until spawn-time archive resolution fails.
- This violates the intent that profile updates re-run Backend discovery/validation and Worker launch candidates share the same effective validated discovery result.
3. Registry/default updates can commit references that are not actually Backend-published/valid。
- `validate_default_profile` accepts any string beginning with `project:` without checking that the project selector exists in submitted registry or discovery result (`profile_settings.rs:946-955`).
- `update_profile_registry` only validates source content if the expected source file already exists (`profile_settings.rs:463-468`) before writing the registry (`profile_settings.rs:470`).
- This permits API writes that persist nonexistent project defaults or missing project source references, contrary to the invariant that writes validate schema/selector/source references before commit and defaults/candidates are Backend-published.
Validation performed:
- Read Ticket `00001KX0DSMPT` item/thread, IntentPacket, binding invariants, acceptance criteria, implementation report, and reviewer focus。
- Read-only inspection in implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings`
- Inspected implementation commit scope and relevant backend/frontend files。
- Relied on Orchestrator-provided validation results; did not modify files or run root/original workspace operations。
Non-blocking follow-ups:
- Negative-test coverage is thinner than reviewer focus: duplicate selector, invalid registry schema, symlink escape, artifact size, redaction, concurrent update API response shape, and launch-candidate invalidation need stronger coverage.
- UI implements profile list/editor on single `/settings` page with anchors/forms rather than separate `/settings/profiles/...` pages. This may be acceptable under implementation latitude, but confirm route granularity if it matters.
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T13:07:39Z -->
## Implementation report
Review-fix implementation report:
- Recovery Coder Pod completed requested changes and committed follow-up `0c2ca1ea fix: harden profile settings validation` on branch `work/00001KX0DSMPT-workspace-profile-settings`.
- Worktree status was clean after commit。
- Fix summary:
- Profile settings validation errors now map to typed Browser diagnostics with non-empty `diagnostics` arrays and appropriate HTTP statusesrevision conflict 409、invalid/duplicate/path/schema/source/profile errors 400、unknown source/selector 404
- Project profile discovery attaches source/Decodal validation failures to the relevant `WorkspaceProfileSummary`
- Invalid project profiles are excluded from Worker launch candidates and rejected by candidate validation。
- Registry/default writes validate default selector existence, selector/source consistency, missing source files, path/symlink escape, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before commit。
- Added/strengthened tests for duplicate selector, invalid Decodal/schema, stale revision conflict, too-large source, symlink escape redaction, invalid launch candidate rejection, and missing/default reference 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 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
- Requesting follow-up external review against `3eefd333..0c2ca1ea` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-08T13:18:55Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Validation performed read-only in implementation worktree. Reviewer inspected Ticket records, `0c2ca1ea`, fix diff `3eefd333..0c2ca1ea`, and relevant server/profile/UI code. Reviewer did not rerun heavy validation and relied on Orchestrator-reported passing validation.
Findings:
- Prior blocker 1 resolved: profile mutation failures now return typed Browser diagnostics with populated diagnostics rather than generic empty diagnostics. Server-side handlers/tests cover invalid source mutations and missing-source registry mutations with non-empty diagnostic arrays and bad-request status。
- Prior blocker 2 resolved: invalid/missing/Decodal-invalid project profile sources are surfaced on relevant profile summaries, invalid project profiles are excluded from Worker launch candidates, and candidate validation rejects invalid project profile ids with regression coverage。
- Prior blocker 3 resolved: registry/default update paths validate selector/default existence, selector/source-id correspondence, missing source files, source path safety including symlink escape rejection, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before accepting mutations。
- Browser-facing profile/settings summaries expose bounded ids, labels, relative display paths, revision/configured booleans, and diagnostics; no host absolute paths, socket/session paths, runtime-local store paths, runtime endpoints/tokens, backend-private paths, archive digests, raw resource handles, or secret values were found in inspected API/UI surfaces。
- Profile detail exposes editable source content as intended settings-editor payload, not resolved archive/resource bundle。
- Decodal/ProfileSourceArchive validation remains behind server-side checked source path/archive boundary; Runtime filesystem discovery was not reintroduced for profile validation or launch。
- Single-page settings UI route structure is acceptable under implementation latitude and not a blocker。
Non-blocking follow-up:
- If merge target accumulated additional `.yoi/tickets/00001KX0DSMPT/thread.md` edits, resolve Ticket-record conflicts normally during merge; this does not affect product/code correctness.
---
<!-- event: state_changed author: orchestrator at: 2026-07-08T13:20:09Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fixes。
- Implementation branch `work/00001KX0DSMPT-workspace-profile-settings` was merged into the orchestration branch with merge commit `0bf3638c merge: workspace profile settings`
- 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`: pass91 lib tests + 2 main 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
Implementation commits:
- `3eefd333 feat: add workspace profile settings`
- `0c2ca1ea fix: harden profile settings validation`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-08T13:20:27Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-08T13:20:27Z status: closed -->
## 完了
完了。
実装内容:
- Workspace/Profile settings scoped API を追加した。
- Workspace metadata read/update と safe identity/source summaries を扱う Settings API を追加した。
- Profile registry/source read/update API を追加し、create/update/delete と revision/etag-style optimistic checks を実装した。
- Backend profile settings model に safe source ids/display paths、Decodal/ProfileSourceArchive validation、selector uniqueness、path/symlink escape protection、source size checks、typed diagnostics を追加した。
- Profile updates 後に Backend profile discovery / validation / launch options を再構築し、Worker launch profile candidates と Settings profile list が同じ discovery result を使うようにした。
- invalid project profile は relevant profile summary に diagnostics を付与し、Worker launch candidates から除外し、candidate validation でも reject するようにした。
- Browser-facing responses/UI から host absolute paths、secret values、Runtime endpoints/tokens、socket/session paths、runtime-local store paths、archive content/digest、raw resource handles、backend-private paths を出さないようにした。
- Settings UI に Workspace overview と Profile list/source editor surface を追加した。
- Focused backend/web tests を追加/強化した。
主な commit / merge:
- implementation: `3eefd333 feat: add workspace profile settings`
- review fix: `0c2ca1ea fix: harden profile settings validation`
- merge into orchestration: `0bf3638c merge: workspace profile settings`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p yoi-workspace-server`: pass91 lib tests + 2 main 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
補足:
- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。
---

View File

@ -0,0 +1,3 @@
{"id":"orch-plan-20260708-100506-1","ticket_id":"00001KX0G06VA","kind":"before","related_ticket":"00001KWZ5KERY","note":"Ticket body defines this resource fetch REST API as the prerequisite for `00001KWZ5KERY` Decodal ProfileSourceArchive prefetch/verify. Current workspace already has `00001KWZ5KERY` inprogress on branch `work/00001KWZ5KERY-decodal-profile-archive`, with overlapping worker-runtime/workspace-server surfaces and external review pending. Do not parallelize/start this Ticket until the active Decodal branch outcome is known and an integration order is chosen.","author":"orchestrator","at":"2026-07-08T10:05:06Z"}
{"id":"orch-plan-20260708-100516-2","ticket_id":"00001KX0G06VA","kind":"waiting_capacity_note","note":"Queue review found this Ticket otherwise implementation-ready, but it is intentionally left queued because `00001KWZ5KERY` is already inprogress/review on the same worker-runtime/workspace-server ProfileSourceArchive surfaces while this Ticket text states it should precede that work. Starting now would create a high-conflict branch and could invalidate the active Decodal review. Re-route/start after the active Decodal branch is approved/blocked or after an explicit integration-order decision.","author":"orchestrator","at":"2026-07-08T10:05:16Z"}
{"id":"orch-plan-20260708-103903-3","ticket_id":"00001KX0G06VA","kind":"accepted_plan","accepted_plan":{"summary":"Decodal ProfileSourceArchive work `00001KWZ5KERY` は完了・merge・closed 済みになったため、前回の waiting reason は解除された。`00001KX0G06VA` を順当に開始し、resource fetch REST API を Decodal implementation 後続として実装する。","branch":"work/00001KX0G06VA-resource-fetch-api","worktree":"/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Runtime-to-Backend resource handle / broker / direct+HTTP fetch contract / Runtime client-cache / profile_source_archive v0 integration / authority-boundary tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / Decodal integration / redaction・authority invariants に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-08T10:39:03Z"}

View File

@ -0,0 +1,156 @@
---
title: 'Add Runtime-to-Backend resource fetch REST API'
state: 'closed'
created_at: '2026-07-08T09:12:33Z'
updated_at: '2026-07-08T11:54:55Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-08T10:04:10Z'
---
## 背景
Runtime は Worker execution host であり、Workspace filesystem を直接読まない。一方で Worker 実行中または Worker 作成時には、Backend が正本を持つ Workspace-derived resource を Runtime が取得する経路が必要になる。
例:
- Decodal Profile launch の `ProfileSourceArchive` prefetch。
- Memory / Knowledge の read/query/write tool backend。
- Ticket / Objective の read/query/update tool backend。
- Workspace metadata / repository metadata の bounded lookup。
- 将来の Backend-owned resource / prompt / policy artifact lookup。
これらを Runtime-local filesystem discovery や ad hoc endpoint で増やすと、Workspace authority、credential、redaction、audit、capability の境界が崩れる。本 Ticket は Runtime -> Backend の情報取得を、Runtime-internal REST API と Backend-issued resource handle として実装する。
## 実装順序
1. 本 Ticket `00001KX0G06VA`: Runtime -> Backend の resource fetch REST API を実装する。
2. Ticket `00001KWZ5KERY`: Decodal `ProfileSourceArchive` prefetch/verify は、本 Ticket の REST API を使って Backend から archive を取得する。
3. Ticket `00001KX0DSMPT`: Workspace/Profile settings UI/API は Decodal source model に合わせて実装する。
4. Memory / Ticket / Objective tool backend の Runtime -> Backend 接続は後続 Ticket で、この REST API 上の resource/capability として実装する。
## 実装目的
- Runtime が Workspace filesystem を読まずに、Backend-owned resource を取得できる。
- Backend が Workspace information の authority として access control / redaction / audit を持つ。
- Runtime は Backend-issued resource handle なしに任意 Workspace information を取得できない。
- Browser-facing API に Runtime -> Backend resource handle、Backend internal endpoint、credential、raw path を露出しない。
- `ProfileSourceArchive` prefetch のような Worker creation-time fetch と、Memory/Ticket/Objective のような Worker runtime-time lookup の両方に拡張できる。
## 実装内容
### 1. Resource handle model を追加する
Backend が Runtime に渡せる typed resource handle を定義する。
handle に含めるもの:
- resource kind。
- workspace id / scope id。
- resource id または content digest。
- operation: read / query / write / append / fetch archive などの最小権限。
- expiry / nonce / revision / generation。
- redaction policy / max bytes / content type。
- audit correlation id。
handle に含めないもの:
- raw Backend URL。
- Runtime endpoint / token。
- host absolute path。
- secret value。
- Browser request 由来の raw filesystem scope。
### 2. Runtime-internal Backend REST API を実装する
Runtime が Backend-owned resource を取得する REST API を追加する。v0 は request/response fetch でよく、WebSocket や persistent bidirectional channel は使わない。
想定 endpoint:
```text
POST /internal/runtime/resources/fetch
```
実装すること:
- embedded Runtime direct call path。
- remote Runtime HTTP path。
- request/response typed schema。
- runtime credential / connection authority validation。
- bounded bytes / optional streaming or chunking policy。
- timeout / cancellation / retry policy。
- missing / expired / unauthorized / unsupported resource diagnostics。
- response digest validation。
### 3. Backend resource broker を実装する
Backend は resource handle を検証し、対応する Workspace-derived resource を返す。
実装すること:
- handle validation。
- workspace id / runtime id / worker id binding validation。
- resource kind dispatch。
- redaction / max bytes / content type validation。
- audit log / diagnostic。
- stale revision / digest mismatch handling。
v0 resource kind:
- `profile_source_archive`: `ProfileSourceArchive` bytes fetch。
後続 resource kind:
- `memory_query` / `memory_read` / `memory_write`
- `ticket_read` / `ticket_update`
- `objective_read` / `objective_update`
- `workspace_metadata_read`
### 4. Runtime-side REST client / cache を追加する
Runtime は Backend resource handle を受け取り、必要な resource を REST fetch / verify / cache できる。
実装すること:
- resource fetch REST client。
- digest / revision / content type validation。
- bounded cache for immutable/digest-addressed artifacts。
- mutable resource は cache しないか、short TTL / revision validation を使う。
- diagnostics を Worker creation / tool call result に接続する。
### 5. Authority boundary tests を追加する
Runtime -> Backend fetch が Workspace filesystem bypass や raw endpoint leak にならないことを test する。
検証すること:
- Runtime が raw path を知らずに `profile_source_archive` を取得できる。
- expired/unauthorized handle は拒否される。
- wrong runtime / wrong worker / wrong workspace binding は拒否される。
- response digest mismatch は拒否される。
- Browser-facing response に resource handle / internal endpoint / credential / raw path が出ない。
## 受け入れ条件
- Runtime -> Backend resource fetch REST API が typed request/response schema として実装されている。
- Backend が resource handle を発行・検証し、v0 resource kind `profile_source_archive` を返せる。
- Runtime が resource handle から `ProfileSourceArchive` を REST fetch / verify / cache できる。
- embedded Runtime direct call path と remote Runtime HTTP path が同じ resource contract を使う。
- Runtime は Workspace filesystem を読まず、Backend resource handle なしに Workspace-derived resource を取得できない。
- expired handle、unauthorized handle、workspace/runtime/worker mismatch、missing resource、digest mismatch、oversized response は typed diagnostic になる。
- Browser-facing API に Backend internal endpoint、resource credential、raw path、resource handle が露出しない。
- Focused tests が direct path、remote path、authorization mismatch、digest mismatch、Browser redaction を確認する。
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo check -p yoi` が通る。
- `yoi ticket doctor` が通る。
- `nix build .#yoi --no-link` が通る。
## 非目標
- Memory / Ticket / Objective tools の実装本体。
- ProfileSourceArchive tar format / Decodal resolver 本体。これは `00001KWZ5KERY` で扱う。
- Workspace/Profile editing UI。これは `00001KX0DSMPT` で扱う。
- WebSocket / persistent bidirectional resource channel。
- Secret value synchronization。
- Plugin package manager / artifact sync。

View File

@ -0,0 +1,36 @@
完了。
実装内容:
- Runtime-to-Backend resource fetch contract を追加した。
- Backend-issued typed resource handle と `BackendResourceBroker` を実装した。
- v0 resource kind `profile_source_archive` を fetch / verify / cache できるようにした。
- embedded direct Runtime path と remote Runtime HTTP path が同じ typed resource contract を使うようにした。
- Runtime は handle に基づき archive を fetch し、digest / max bytes / content type / expiry / binding を検証してから cache/reuse するようにした。
- Backend broker は stored issued handle を authority として扱い、Runtime-provided mutable handle fields による expiry / max_bytes / policy tampering を fail closed するようにした。
- Runtime cache は Backend authorization / expiry / binding を bypass しないようにした。
- Decodal ProfileSourceArchive / config bundle path を resource fetch boundary に接続し、Runtime filesystem discovery を再導入しない形にした。
- Browser-facing summaries は internal resource handle / endpoint / raw path / archive content/digest/store path / token 等を出さないようにした。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `57e96d3b feat: add backend resource fetch api`
- review fix: `e716ae44 fix: harden backend resource handles`
- merge into orchestration: `01f94b75 merge: backend resource fetch api`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass45 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass77 lib tests + 2 main 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
補足:
- Reviewer の non-blocking follow-up: stored `audit_correlation_id` echo と Runtime-side response `resource_id` check、remote HTTP response-size limiting のさらなる強化余地。
- queued `00001KX0DSMPT` については、resource-fetch API shape が確定したため再 routing 可能。

View File

@ -0,0 +1,397 @@
<!-- event: create author: "yoi ticket" at: 2026-07-08T09:12:33Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-08T09:27:52Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-08T09:27:52Z 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-08T10:04:10Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-08T10:05:32Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Ticket 自体は Runtime-to-Backend resource fetch REST API として実装 intent / acceptance criteria が見えるが、Ticket body は `00001KWZ5KERY` Decodal ProfileSourceArchive work の前段として扱う順序を明記している。
- 現在 `00001KWZ5KERY` はすでに `inprogress` で、implementation branch `work/00001KWZ5KERY-decodal-profile-archive` があり、外部 review 待ち。
- 両 Ticket は worker-runtime / workspace-server / ProfileSourceArchive prefetch/verify の同一 surface に触れるため、今この Ticket を別 branch で開始すると高確率で conflict し、active Decodal review の前提を壊す。
- したがってこの routing pass では `queued -> inprogress` を記録せず、worktree 作成 / Pod spawn などの implementation side effect は行わない。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KX0G06VA)`: typed relation 0 件。
- `TicketOrchestrationPlanQuery(00001KX0G06VA)`: prior record 0 件だったため、今回 `before 00001KWZ5KERY` と waiting-capacity note を記録。
- `TicketList`: queued はこの Ticket 1件、inprogress は `00001KWZ5KERY` 1件。
- Orchestrator worktree git status: clean on `orchestration`
- `00001KWZ5KERY` implementation branch exists and is under review。
Next action:
- `00001KWZ5KERY` の review 結果を待つ。
- review が request_changes で resource-fetch API prerequisite が必要と確認された場合、または Decodal branch をどう扱うかの integration-order decision が明確になった後、この Ticket を再 routing して start する。
- Decodal branch が approve された場合も、この Ticket を後続で必要とするか、Decodal implementation を resource-fetch API に合わせて follow-up refactor するかを明示的に判断してから開始する。
Escalate if:
- active Decodal branch を中断/rebase/drop して、この Ticket を先に実装する方針に切り替える必要がある場合。
- resource-fetch API の public/auth/capability model が Decodal Ticket の recorded invariants を変える必要がある場合。
---
<!-- event: decision author: orchestrator at: 2026-07-08T10:38:51Z -->
## Decision
Routing decision: implementation_ready
Reason:
- 前回この Ticket を queued のまま保持した理由は、`00001KWZ5KERY` が inprogress/review 中で、同一 worker-runtime / workspace-server / ProfileSourceArchive prefetch surface に触れるためだった。
- 現在 `00001KWZ5KERY` は closed になり、implementation branch も orchestration branch に merge 済みで、previous blocker は解消している。
- この Ticket は Runtime -> Backend resource fetch REST API / resource handle / broker / Runtime client/cache / authority-boundary tests の intent と acceptance criteria が具体化されている。
- typed relation blocker は 0 件。既存 OrchestrationPlan の `before 00001KWZ5KERY` / waiting note は過去の並列停止理由として確認したが、Decodal 側が完了済みのため現在の acceptance blocker ではない。
- queued notification と今回の user follow-up「ないなら順当に消化して」により、human authorized routing/start の条件を満たす。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KX0G06VA)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KX0G06VA)`: prior `before 00001KWZ5KERY` と waiting note を確認。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。inprogress は 0 件。
- `00001KWZ5KERY` は close 済みで、merge commit `0334c572` と close commit `ffc16dea` が orchestration branch にある。
IntentPacket:
Intent:
- Runtime が Workspace filesystem を直接読まずに Backend-owned resource を取得できる Runtime-to-Backend resource fetch REST API を追加する。
- Backend-issued typed resource handle と Backend resource broker を導入し、v0 resource kind `profile_source_archive` を fetch / verify / cache できるようにする。
- 既存 Decodal ProfileSourceArchive implementation を、この resource fetch boundary に接続または将来接続しやすい形に整理する。
Binding decisions / invariants:
- Browser-facing API に Backend internal endpoint、resource credential、resource handle、raw path、Runtime endpoint/token/socket/session path を出さない。
- Runtime は Backend-issued resource handle なしに Workspace-derived resource を取得できない。
- handle には resource kind、workspace/scope id、resource id/digest、operation、expiry/nonce/revision/generation、redaction/max-bytes/content-type、audit correlation id を含める。
- handle には raw Backend URL、host absolute path、secret value、Browser request 由来 raw filesystem scope を含めない。
- v0 resource kind は `profile_source_archive`。Memory/Ticket/Objective tool backend 本体は非目標。
- Runtime-local filesystem discovery を増やさず、Backend authority / redaction / audit / capability 境界を保つ。
- WebSocket/persistent bidirectional channel は非目標。v0 は request/response REST/direct call。
Requirements / acceptance criteria:
- typed request/response schema の Runtime -> Backend resource fetch API がある。
- embedded Runtime direct call path と remote Runtime HTTP path が同じ resource contract を使う。
- Backend が resource handle を発行/検証し、`profile_source_archive` bytes を返せる。
- Runtime が resource handle から archive を fetch / digest verify / cache できる。
- expired / unauthorized / workspace-runtime-worker mismatch / missing resource / digest mismatch / oversized response は typed diagnostic。
- Browser-facing response redaction が tests で確認される。
Implementation latitude:
- exact endpoint/module/type names、cache layout、handle signing/nonce representation、audit record shape、profile archive integration depth は既存 Runtime/Workspace Server style に合わせてよい。
- 既存 `ConfigBundle` / `ProfileSourceArchive` machinery は維持しつつ、resource handle fetch boundary を追加する形でよい。
- v0 で remote Runtime full auth hardening が不足する場合は typed diagnostic と focused tests で境界を示す。
Escalate if:
- secret synchronization、Memory/Ticket/Objective tool implementations、persistent channel、Plugin package manager、multi-tenant auth、public Browser handle exposure、or Decodal archive format redesign が必要になる場合。
Validation:
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server`
- `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:
- Decodal/Profile archive current implementation: `crates/worker-runtime/src/profile_archive.rs`, `config_bundle.rs`, `worker_backend.rs`, `runtime.rs`, `http_server.rs`, `catalog.rs`
- Backend/Runtime bridge: `crates/workspace-server/src/hosts.rs`, `server.rs`, `companion.rs`
- Protocol/runtime request shapes: `crates/protocol/src/lib.rs`, `crates/worker-runtime` request/response structs。
Critical risks / reviewer focus:
- resource handles must not become Browser-visible bearer authorities。
- endpoint/token/raw path/internal store/runtime details must stay out of Browser-facing surfaces。
- direct and remote Runtime paths must share contract rather than diverging ad hoc。
- digest/max-bytes/content-type validation must fail closed。
- profile archive fetch integration must not reintroduce Runtime workspace filesystem discovery。
---
<!-- event: state_changed author: orchestrator at: 2026-07-08T10:39:16Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
## State changed
Queued acceptance recorded after user follow-up 「06VAが進まない理由は何ないなら順当に消化して」。
Checked context:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KX0G06VA)`: blocking relation 0 件。
- `TicketOrchestrationPlanQuery(00001KX0G06VA)`: prior waiting note は `00001KWZ5KERY` inprogress/review によるもの。`00001KWZ5KERY` は now closed / merged。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。
Acceptance basis:
- previous waiting reason は解消済み。
- concrete missing decision / information は残っていない。
- Ticket の scope は Runtime-to-Backend resource fetch REST API v0 と `profile_source_archive` kind に閉じており、Memory/Ticket/Objective tool backend 本体、persistent channel、secret sync、plugin package manager は非目標として明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T10:40:25Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api`
- Branch: `work/00001KX0G06VA-resource-fetch-api`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KX0G06VA-resource-fetch` を起動した。
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T11:20:23Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `57e96d3b feat: add backend resource fetch api` on branch `work/00001KX0G06VA-resource-fetch-api`.
- Worktree status was clean after commit.
- Changed files include:
- `crates/worker-runtime/src/resource.rs` (new)
- `crates/workspace-server/src/resource_broker.rs` (new)
- `crates/worker-runtime/src/{config_bundle.rs,http_server.rs,main.rs,profile_archive.rs,worker_backend.rs}`
- `crates/workspace-server/src/{hosts.rs,server.rs,companion.rs}`
- `Cargo.lock`, `package.nix`
- Implementation summary:
- Added typed resource handle/request/response model and Runtime resource client/cache path。
- Added Backend resource broker for v0 `profile_source_archive` resources。
- Embedded direct path and remote Runtime HTTP path share the resource contract。
- Runtime verifies digest, max bytes/content type, expiry/binding, and cache reuse before using fetched profile archives。
- Config bundle/profile archive path can use resource handle without Runtime filesystem discovery。
- Browser-facing summaries redact internal resource handles/endpoints/raw paths/archive details。
- Added focused tests for handle issue/verify, runtime mismatch, digest mismatch, oversized response, direct/remote resource fetch, Browser redaction, and config-bundle/resource archive integration。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass42 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass71 lib tests + 2 main 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-08T11:27:23Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blockers:
1. Backend handle verification が Runtime から送られた mutable handle fields を信頼している。
- `crates/workspace-server/src/resource_broker.rs` の fetch validation は expiry / max-bytes を `request.handle` から確認しており、broker-stored issued handle の authority fields と照合していない。
- 有効な nonce/resource/digest を持つ Runtime が以下を改変できる可能性がある。
- `expires_at_unix_seconds` を延長して expiry を bypass。
- `max_bytes` を増やして oversized rejection を bypass。
- redaction/scope/generation など他 policy fields も stored issued handle に bind されていない。
- Backend-issued handle を Backend が検証し、expired/oversized handles が fail closed typed diagnostics になるという Ticket requirement に反する。
2. Runtime cache が Backend authorization / expiry / binding を bypass している。
- `ProfileRuntimeWorkerFactory::resolve_profile_source_archive` は fetch 前に digest keyed `profile_archive_cache` を見る。
- 一度 archive が cache されると、同一 digest の後続 config bundle は Backend verification なしで reuse でき、expired handle / runtime-worker-workspace mismatch でも成功し得る。
- 「Runtime cannot fetch/use Workspace-derived resources without Backend-issued handle」「Runtime verifies expiry/binding」「expired/unauthorized/mismatch cases produce diagnostics」に反する。
3. security contract の acceptance-level tests が不足している。
- happy-path broker issue/fetch、runtime mismatch、browser projection redaction はあるが、以下の meaningful coverage が不足している。
- tampered expired handle / expiry extension。
- worker mismatch。
- REST 経由 missing resource。
- Runtime response verification の digest mismatch。
- tampered `max_bytes` による oversized response。
- remote HTTP resource-fetch path が同じ contract を使うこと。
- 上記 defect が存在しているため、単なる coverage gap ではなく core Ticket intent の boundary regression を許している。
Validation / inspection performed:
- implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api` のみを使用。
- Ticket item/thread/orchestration plan を確認。
- `git show --stat --check 57e96d3b`, `git show --name-only 57e96d3b`, relevant implementation files を inspection。
- Orchestrator-reported validation pass を参照し、Reviewer は heavy tests を再実行していない。
Non-blocking follow-ups:
- New `reqwest` dependency and `package.nix` cargo hash update は HTTP Runtime resource client と `nix build` pass により妥当。
- broader resource kind 追加前に、stored issued handle policy fields の完全比較、または signed/opaque handle representation を検討するとよい。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-08T11:45:42Z -->
## Implementation report
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `e716ae44 fix: harden backend resource handles` on branch `work/00001KX0G06VA-resource-fetch-api`.
- Worktree status was clean after commit.
- Fix summary:
- Backend broker now treats broker-stored issued handle as authority。
- Request handle must match stored issued handle; expiry and `max_bytes` validation use stored policy fields, not mutable Runtime-provided fields。
- Tampered scope/policy fields fail closed。
- Runtime archive cache no longer bypasses Backend authorization: Runtime calls `BackendResourceClient` and verifies response before reusing digest-cached archive。
- Expired/unauthorized handles now block cached reuse。
- Added focused security-contract tests for expiry extension/expired handle rejection, worker mismatch, policy tampering, REST missing-resource diagnostic, Runtime digest mismatch, oversized response despite `max_bytes` tampering, and remote HTTP path using the same Backend contract as direct broker path。
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass45 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass77 lib tests + 2 main 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
- Requesting follow-up external review against `57e96d3b..e716ae44` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-08T11:53:02Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Validation performed:
- Read-only review in implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api`
- Ticket item/thread, branch/log/status, `57e96d3b..e716ae44` fix diff, and relevant implementation files を確認。
- Reviewer は validation suite を再実行せず、Orchestrator-reported passing validation を evidence として確認。
Findings:
- Prior blocker 1 fixed: Backend は mutable request handle policy fields を信頼しない。`BackendResourceBroker` は issued `BackendResourceHandle` を resource と共に保存し、stored expiry/max_bytes を検証してから request handle と stored issued handle を比較する。tampering は `Expired` / `Oversized` / `Unauthorized` diagnostics で fail closed。expiry extension、policy tampering、worker/runtime mismatch、max_bytes tampering tests あり。
- Prior blocker 2 fixed: Runtime cache は Backend authorization を bypass しない。`resolve_profile_source_archive` は先に Backend fetch を行い、handle に対して response verify してから digest cache を reuse/insert する。cache test は second-use backend `Expired` rejection と backend call count を確認している。
- Prior blocker 3 fixed: security contract tests が meaningful に追加されている。
- `resource_broker.rs`: expired/expiry extension、worker mismatch、runtime mismatch、policy tampering、stored max_bytes oversized。
- `resource.rs`: Runtime digest mismatch と oversized response verification。
- `server.rs`: REST missing-resource diagnostic と remote HTTP contract/tampering path。
- `worker_backend.rs`: cache authorization。
- Browser redaction: `ConfigBundleSummary` redaction test。
- Typed/direct/remote contract: `BackendResourceFetchRequest/Response/Error` は shared typed structs。embedded direct path は `BackendResourceBroker``BackendResourceClient` として使い、remote HTTP は同じ request shape を `/internal/runtime/resources/fetch` に POST する。
- Browser-facing redaction remains strong: browser-safe summaries は profile source graph summary のみを出し、handle nonce/resource id/archive digest/runtime-worker binding/token/internal endpoint/raw path を出さない。
- ProfileSourceArchive integration は normal Backend/Browser launch で Backend-owned resource boundary を使う。generated bundles は resource handle を持ち inline archive を持たず、Runtime は fetched archive bytes から resolve する。filesystem profile discovery は explicit no-config-bundle compatibility/debug fallback のみ。
Non-blocking follow-ups:
- stored `audit_correlation_id` の echo と Runtime-side response `resource_id` check を追加すると diagnostic integrity がさらに強くなる。
- remote HTTP resource fetch が trusted Backend/Runtime links を越えて露出する場合は、JSON body materialization 前の explicit client-side timeout/response-size limiting を検討する。
Effect on queued `00001KX0DSMPT`:
- blocking concern なし。profile/archive details は redacted summaries behind boundary に留まっており、future settings UI/API が profile metadata を consume しても handles を露出しない設計と互換。
---
<!-- event: state_changed author: orchestrator at: 2026-07-08T11:54:39Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Merge/validation acceptance:
- Follow-up external review approved the implementation after blocker fix。
- Implementation branch `work/00001KX0G06VA-resource-fetch-api` was merged into the orchestration branch with merge commit `01f94b75 merge: backend resource fetch api`
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass45 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass77 lib tests + 2 main 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
Implementation commits:
- `57e96d3b feat: add backend resource fetch api`
- `e716ae44 fix: harden backend resource handles`
No merge-blocking issues remain. Reviewer noted no blocking concern for queued `00001KX0DSMPT`; the resource-fetch API shape is now stable enough for that follow-up to be re-routed.
---
<!-- event: state_changed author: hare at: 2026-07-08T11:54:54Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-08T11:54:55Z status: closed -->
## 完了
完了。
実装内容:
- Runtime-to-Backend resource fetch contract を追加した。
- Backend-issued typed resource handle と `BackendResourceBroker` を実装した。
- v0 resource kind `profile_source_archive` を fetch / verify / cache できるようにした。
- embedded direct Runtime path と remote Runtime HTTP path が同じ typed resource contract を使うようにした。
- Runtime は handle に基づき archive を fetch し、digest / max bytes / content type / expiry / binding を検証してから cache/reuse するようにした。
- Backend broker は stored issued handle を authority として扱い、Runtime-provided mutable handle fields による expiry / max_bytes / policy tampering を fail closed するようにした。
- Runtime cache は Backend authorization / expiry / binding を bypass しないようにした。
- Decodal ProfileSourceArchive / config bundle path を resource fetch boundary に接続し、Runtime filesystem discovery を再導入しない形にした。
- Browser-facing summaries は internal resource handle / endpoint / raw path / archive content/digest/store path / token 等を出さないようにした。
- Focused worker-runtime / workspace-server tests を追加した。
主な commit / merge:
- implementation: `57e96d3b feat: add backend resource fetch api`
- review fix: `e716ae44 fix: harden backend resource handles`
- merge into orchestration: `01f94b75 merge: backend resource fetch api`
Review:
- 初回 external review は request_changes。
- follow-up external review は approve。merge-blocking issue なし。
Final validation in Orchestrator worktree:
- `git diff --check`: pass
- `cargo test -p worker-runtime --features ws-server,fs-store`: pass45 lib tests + 5 main tests + doc tests
- `cargo test -p yoi-workspace-server`: pass77 lib tests + 2 main 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
補足:
- Reviewer の non-blocking follow-up: stored `audit_correlation_id` echo と Runtime-side response `resource_id` check、remote HTTP response-size limiting のさらなる強化余地。
- queued `00001KX0DSMPT` については、resource-fetch API shape が確定したため再 routing 可能。
---

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KX0V3DB0",
"kind": "depends_on",
"target": "00001KX0G06VA",
"note": "Ticket/Objective Runtime tool access should use the Runtime-to-Backend resource fetch REST API and handle boundary.",
"author": "yoi ticket",
"at": "2026-07-08T12:27:44Z"
}
]
}

View File

@ -0,0 +1,148 @@
---
title: 'Route Ticket and Objective access through Backend APIs'
state: 'planning'
created_at: '2026-07-08T12:26:33Z'
updated_at: '2026-07-08T12:27:44Z'
assignee: null
---
## 背景
Ticket / Objective は Workspace product state であり、Backend が authority を持つ。Runtime / Worker / tools が `.yoi/tickets``.yoi/objectives` を直接読む構造を続けると、Workspace filesystem authority が Runtime 側へ漏れ、Browser API / Runtime API / tool 実装の境界が崩れる。
今後は Ticket / Objective の read/query/update は Backend API 経由に寄せる。Backend は既存の typed Ticket/Objective backend を内部実装として使ってよいが、Runtime/Worker/tools からは raw filesystem path や local backend root を見せない。
Memory / Workflow も同じ方向だが、Memory は抽出・consolidation・正本ストア・更新頻度の問題があり、Workflow は active workflow state / compaction / runtime state の問題があるため、本 Ticket では扱わず別途設計・実装 Ticket に分離する。
## 実装順序
- depends_on: `00001KX0G06VA` — Runtime -> Backend resource fetch REST API / resource handle 境界を先に使えるようにする。
- 本 Ticket: Ticket / Objective の Backend API と tool 経路の切り替えを実装する。
- 後続: Memory / Knowledge access と Workflow state access は、抽出・正本・更新モデルを整理した別 Ticket で扱う。
## 実装目的
- Browser / Runtime / Worker tools は Ticket / Objective を Backend API 経由で扱う。
- Runtime は `.yoi/tickets` / `.yoi/objectives` / local backend root を直接読まない。
- Ticket / Objective tool 実装は Backend-owned API/resource capability を使う。
- Backend は Ticket / Objective の read/query/update authority、validation、audit、diagnostic を持つ。
- Browser-facing API と Runtime-internal API は同じ Backend service layer を使うが、authority / redaction / mutation affordance は分ける。
## 実装内容
### 1. Backend service layer を追加する
Workspace Backend 内に Ticket / Objective service layer を追加する。これは既存 typed backend を内包し、Browser-facing API と Runtime-internal resource/tool API の共通実装になる。
実装すること:
- Ticket list / show / thread / artifacts / relations / resolution read。
- Ticket comment / review / state transition / close mutation。
- Objective list / show / relation / update / comment 相当の read/mutation。
- state transition validation / lifecycle authority / optimistic update validation。
- typed diagnostics。
- audit event / correlation id。
### 2. Browser-facing Ticket / Objective API を追加・整理する
Workspace-scoped Browser API から Ticket / Objective を read/update できるようにする。
想定 endpoint:
- `GET /api/w/<workspace-id>/tickets`
- `GET /api/w/<workspace-id>/tickets/<ticket-id>`
- `POST /api/w/<workspace-id>/tickets/<ticket-id>/comments`
- `POST /api/w/<workspace-id>/tickets/<ticket-id>/reviews`
- `POST /api/w/<workspace-id>/tickets/<ticket-id>/state`
- `POST /api/w/<workspace-id>/tickets/<ticket-id>/close`
- `GET /api/w/<workspace-id>/objectives`
- `GET /api/w/<workspace-id>/objectives/<objective-id>`
- Objective update/comment endpoint は既存 Objective backend capability に合わせて追加する。
Browser-facing response に出さないもの:
- host absolute path。
- local backend root。
- runtime endpoint / resource handle / credential。
- session/socket/cache path。
- raw secret-like artifact contents。
### 3. Runtime-internal resource/tool API を追加する
Runtime / Worker tools が Ticket / Objective を扱うための Runtime-internal resource kinds を追加する。
追加する resource kind:
- `ticket_list`
- `ticket_read`
- `ticket_comment`
- `ticket_review`
- `ticket_state_update`
- `ticket_close`
- `objective_list`
- `objective_read`
- `objective_update`
- `objective_comment`
実装すること:
- Backend-issued handle / capability を検証する。
- runtime id / worker id / workspace id / operation binding を検証する。
- read と mutation の authority を分ける。
- mutation は typed Backend service layer を通す。
- diagnostics を Worker tool result に返せる形にする。
### 4. Tool 実装を Backend API 経由へ切り替える
Worker/Runtime tool 実装が local filesystem backend を直接開かないようにする。
対象:
- Ticket list/show/comment/review/state/close 系 tool。
- Objective list/show/update/comment 系 tool。
- Orchestrator / Intake / Reviewer / Coder role tools が使う Ticket/Objective access path。
実装すること:
- tool context に Backend resource client / capability を渡す。
- tool は Backend API を呼ぶ。
- local backend direct path は Backend process 内または compatibility-only tests に隔離する。
- Runtime が Workspace filesystem authority を持たない状態でも tools が動くことを確認する。
### 5. Panel / Workspace UI の既存直接読みに注意する
Panel / Workspace Browser が local file projection を持つ場合も、今後の authority は Backend API に寄せる。v0 で完全移行できない場合は compatibility path として明示する。
実装すること:
- Browser Workspace UI は Backend API を使う。
- local file scan / heuristic projection が残る場合は read-only compatibility とし、mutation authority にはしない。
- Ticket lifecycle authority は typed Backend service layer に集約する。
## 受け入れ条件
- Workspace Backend に Ticket service layer と Objective service layer が実装されている。
- Browser-facing API で Ticket list/show/comment/review/state/close ができる。
- Browser-facing API で Objective list/show/update/comment 相当ができる。
- Runtime-internal resource kinds for Ticket / Objective が実装されている。
- Ticket / Objective tools は Runtime から local `.yoi` filesystem を読まず Backend API/resource handle 経由で動く。
- Runtime が local Ticket/Objective backend root を持たない構成で focused tests が通る。
- mutation は Backend service layer の lifecycle validation / optimistic update / audit を通る。
- Browser-facing response と Worker tool result に host absolute path、backend root、runtime endpoint、resource credential、secret-like artifact contents が露出しない。
- unauthorized / expired handle、wrong runtime/worker/workspace binding、invalid lifecycle transition、stale revision、missing record は typed diagnostic になる。
- Existing `yoi ticket` CLI / local typed backend は Backend internal implementation または compatibility surface として壊れない。
- `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` が通る。
## 非目標
- Memory / Knowledge API/tool migration。抽出・consolidation・正本・更新頻度を含めて別 Ticket で扱う。
- Workflow / active workflow state API/tool migration。compaction / runtime state / history との関係を別 Ticket で扱う。
- Multi-user auth / RBAC の完成。
- Ticket schema migration。
- Objective schema redesign。
- Browser full project-management UI の完成。

View File

@ -0,0 +1,7 @@
<!-- event: create author: "yoi ticket" at: 2026-07-08T12:26:33Z -->
## 作成
LocalTicketBackend によって作成されました。
---

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KX0ZJN5B",
"kind": "depends_on",
"target": "00001KX0G06VA",
"note": "Memory tool read/query and Backend-owned observation pipeline should use the Runtime-to-Backend resource/API boundary.",
"author": "yoi ticket",
"at": "2026-07-08T13:46:41Z"
}
]
}

View File

@ -0,0 +1,182 @@
---
title: 'Move Memory extraction and consolidation to Backend observation pipeline'
state: 'planning'
created_at: '2026-07-08T13:44:47Z'
updated_at: '2026-07-08T13:46:41Z'
assignee: null
---
## 背景
Memory / Knowledge は Workspace product state であり、更新頻度が高く、自動抽出・consolidation によって人間の明示操作なしに変化する。Runtime / Worker が `.yoi/memory`、staging、consolidation lock、memory record を直接読む・書く構造を続けると、Workspace filesystem authority が Runtime 側に漏れ、child WorkingDirectory / remote Runtime / Backend API 境界と衝突する。
既存実装では、Worker post-run hook が memory extract worker を動かし、`<workspace>/.yoi/memory/_staging/<id>.json` に staging を書き、別の consolidation worker が `_staging` を snapshot/lock して `memory/*` / `knowledge/*` に統合する。これは local Worker では動くが、Runtime を Workspace filesystem から切り離す設計とは合わない。
今後は Runtime / Worker は Memory 正本を持たず、turn / transcript / tool usage / source refs から bounded な `MemoryObservation` を Backend に push する。Backend は Memory/Knowledge の正本、staging、audit、extraction、consolidation、tool read/write/query authority を持つ。
Workflow / active workflow state も同様に Backend API 境界へ寄せる必要があるが、compaction / history / runtime state と絡むため本 Ticket の非目標とする。
## 実装順序
- depends_on: `00001KX0G06VA` — Runtime -> Backend resource fetch REST API / handle 境界。Memory tool read/query の基礎として利用できる。
- 本 Ticket: Runtime -> Backend memory observation push と Backend-owned extraction/consolidation/store を実装する。
- 後続: Workflow / active workflow state の Backend API 化と Runtime tool/state migration を別 Ticket で扱う。
## 実装目的
- Runtime / Worker は `.yoi/memory` / `.yoi/knowledge` / `_staging` を直接読まない・書かない。
- Runtime / Worker は memory extraction の材料を bounded `MemoryObservation` として Backend に push する。
- Backend が Memory/Knowledge store、staging、audit log、extraction、consolidation、tidy の authority を持つ。
- Memory / Knowledge tools は Backend API 経由で read/query/write/edit する。
- Backend は observation / extraction / consolidation の source provenance、redaction、audit、diagnostics を管理する。
- child WorkingDirectory や remote Runtime が Memory root を持たなくても memory 機能が動く。
## 実装内容
### 1. MemoryObservation model を追加する
Runtime / Worker から Backend へ送る memory extraction input を typed model として定義する。
含めるもの:
- workspace id。
- runtime id / worker id / session id / turn id。
- source segment refs / transcript range refs。
- bounded user / assistant / tool excerpts。
- tool call summaries / tool result summaries。
- active Ticket / Objective refs if known。
- model/provider/provenance metadata。
- extraction trigger reason。
- redaction / privacy flags。
- observed_at timestamp。
含めないもの:
- full unbounded transcript。
- raw host absolute path。
- secret values / credentials / tokens。
- Runtime endpoint / socket path / session store path。
- WorkingDirectory root path。
- raw binary tool outputs。
### 2. Runtime -> Backend observation push API を追加する
Runtime が Backend に MemoryObservation を push する API を追加する。
想定 endpoint:
```text
POST /internal/runtime/memory/observations
```
実装すること:
- embedded Runtime direct call path。
- remote Runtime HTTP path。
- request/response typed schema。
- runtime credential / workspace-runtime-worker binding validation。
- max bytes / max excerpts / max tool summaries / max pending observations。
- timeout / retry / idempotency key。
- rejected / accepted / queued diagnostics。
### 3. Backend memory observation store / audit を追加する
Backend は observation を append-only に受け取り、audit と extraction queue に接続する。
実装すること:
- observation validation。
- source provenance / correlation id。
- append-only observation log。
- duplicate idempotency handling。
- redaction before persistence。
- diagnostic / audit event。
- bounded retention policy。
### 4. Backend-owned extraction pipeline を実装する
Memory extraction worker は Runtime-local filesystem staging ではなく、Backend-owned observation queue を入力にする。
実装すること:
- observation batch selection。
- extraction prompt input builder。
- extracted payload validation。
- staging record creation in Backend-owned store。
- source refs are attached mechanically by Backend, not inferred by LLM。
- extraction failure diagnostics / retry policy。
### 5. Backend-owned consolidation pipeline を実装する
Consolidation は Backend-owned staging / memory / knowledge store に対して行う。
実装すること:
- Backend-owned staging snapshot / lock。
- consolidation threshold by file/count/bytes or observation count/bytes。
- Memory/Knowledge read/write/edit tools for consolidation worker backed by Backend service layer。
- consumed staging cleanup。
- tidy hints / outdated / superseded / unused / noisy handling。
- audit log and UI observability event。
### 6. Memory / Knowledge tools を Backend API 経由に移行する
Runtime / Worker tool implementations for memory and knowledge must call Backend API rather than local memory layout.
対象:
- `MemoryQuery`
- `KnowledgeQuery`
- `MemoryRead`
- `MemoryWrite`
- `MemoryEdit`
- `MemoryDelete`
- Knowledge write/edit/delete equivalents if present。
実装すること:
- tool context に Backend memory client / capability を渡す。
- read/query と mutation の authority を分ける。
- mutation は Backend service layer の validation / lint / audit を通す。
- local direct layout path は Backend internal implementation or compatibility tests に隔離する。
### 7. Compatibility and migration
既存 `.yoi/memory` repo-local store は Backend internal local provider として扱う。Runtime / Worker から直接使わない。
実装すること:
- `memory::WorkspaceLayout` は Backend local provider の内部実装として残してよい。
- `yoi memory lint` CLI は Backend local provider / compatibility command として維持する。
- existing records and staging compatibility migration policy を決める。
- child worktree が `.yoi/tickets` / `.yoi/workflow` を持っていても memory root として扱われない invariant を維持する。
## 受け入れ条件
- `MemoryObservation` typed model が実装されている。
- Runtime -> Backend memory observation push API が embedded direct path と remote HTTP path で実装されている。
- Backend が MemoryObservation を validate / redact / append-only persist / audit できる。
- Backend-owned extraction pipeline が observation から staging record を生成できる。
- Backend-owned consolidation pipeline が Backend-owned staging を Memory/Knowledge 正本へ統合できる。
- Memory / Knowledge tools は Runtime から local `.yoi/memory` filesystem を読まず Backend API/capability 経由で動く。
- Runtime が Memory root / `.yoi/memory` / `.yoi/knowledge` / `_staging` を持たない構成で focused tests が通る。
- source refs / provenance は Backend が機械付与し、LLM に推論させない。
- full unbounded transcript、secret values、raw host path、Runtime endpoint、WorkingDirectory root が observation / tool result / Browser-facing response に露出しない。
- duplicate observation、oversized observation、unauthorized runtime/worker/workspace binding、invalid extracted payload、consolidation lock conflict は typed diagnostic になる。
- Existing `yoi memory lint` and local memory linter behavior remain available as Backend-local/compatibility surface。
- `cargo test -p memory` が通る。
- `cargo test -p worker-runtime --features ws-server,fs-store` が通る。
- `cargo test -p yoi-workspace-server` が通る。
- `cargo check -p yoi` が通る。
- `yoi ticket doctor` が通る。
- `nix build .#yoi --no-link` が通る。
## 非目標
- Workflow / active workflow state API/tool migration。
- Ticket / Objective API/tool migration。これは `00001KX0V3DB0` で扱う。
- Runtime-to-Backend resource fetch REST API foundation。これは `00001KX0G06VA` で扱う。
- Memory schema redesign beyond what is required for observation/staging ownership。
- Vector search / embedding store。
- Multi-user auth / RBAC の完成。
- Browser Memory management UI の完成。

View File

@ -0,0 +1,7 @@
<!-- event: create author: "yoi ticket" at: 2026-07-08T13:44:47Z -->
## 作成
LocalTicketBackend によって作成されました。
---

View File

@ -1,5 +1,8 @@
# Dogfood Workspace Backend configuration.
# Repository URI values are resolved from this workspace config root.
[server]
[data]
[limits]
[[repositories]]
id = "main"
@ -7,3 +10,8 @@ provider = "git"
uri = "."
display_name = "Yoi"
default_selector = "HEAD"
[[runtimes.remote]]
id = "arc"
endpoint = "http://127.0.0.1:38800"
display_name = "arc"

42
Cargo.lock generated
View File

@ -907,6 +907,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "decodal"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4291c87ce887fafc0acf9f40f4bc17e111457e9d62f1b1530113be6b7a7f1a21"
[[package]]
name = "deltae"
version = "0.3.2"
@ -1072,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2543,7 +2549,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3469,7 +3475,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3526,7 +3532,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4114,6 +4120,17 @@ dependencies = [
"libc",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
@ -4136,7 +4153,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5472,7 +5489,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5914,14 +5931,17 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"decodal",
"futures",
"llm-engine",
"manifest",
"protocol",
"reqwest",
"serde",
"serde_json",
"session-store",
"sha2 0.11.0",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
@ -5952,6 +5972,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xml5ever"
version = "0.17.0"

View File

@ -86,6 +86,7 @@ yoi-workspace-server = { path = "crates/workspace-server" }
# need `default-features = false`, which workspace inheritance cannot override.
async-trait = "0.1"
axum = "0.8"
decodal = "0.1.1"
fs4 = "0.13"
futures = "0.3"
libc = "0.2"
@ -93,6 +94,7 @@ schemars = "1.2"
serde = "1.0"
serde_json = "1.0"
serde_yaml = "0.9.34"
tar = "0.4"
rusqlite = { version = "0.37", features = ["bundled"] }
sha2 = "0.11"
tempfile = "3.27"

View File

@ -19,7 +19,7 @@ pub use profile::{
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
resolve_profile_artifact,
resolve_profile_artifact, resolve_profile_artifact_value,
};
pub use protocol::{Permission, ScopeRule};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};

View File

@ -189,6 +189,10 @@ pub enum ProfileSource {
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<String>,
},
Archive {
archive_id: String,
source: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -1405,6 +1409,10 @@ fn source_name(source: &ProfileSource) -> Option<String> {
.and_then(|s| s.to_str())
.map(str::to_string),
ProfileSource::Registry { name, .. } => Some(name.clone()),
ProfileSource::Archive { source, .. } => Path::new(source)
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string),
}
}
fn canonicalize_existing_dir(path: &Path) -> Result<PathBuf, ProfileError> {
@ -1438,12 +1446,21 @@ pub fn resolve_profile_artifact(
source: ProfileSource,
base_dir: &Path,
raw_artifact: serde_json::Value,
) -> Result<ResolvedProfile, ProfileError> {
resolve_profile_artifact_value(raw_artifact, source, base_dir, "artifact-worker")
}
pub fn resolve_profile_artifact_value(
raw_artifact: serde_json::Value,
source: ProfileSource,
base_dir: &Path,
worker_name: &str,
) -> Result<ResolvedProfile, ProfileError> {
resolve_lua_profile_value(
source,
base_dir,
base_dir,
ProfileResolveOptions::with_worker_name("artifact-worker"),
ProfileResolveOptions::with_worker_name(worker_name),
raw_artifact.clone(),
raw_artifact,
None,

View File

@ -13,20 +13,23 @@ required-features = ["ws-server", "fs-store"]
[features]
default = []
fs-store = ["dep:serde_json"]
http-server = ["dep:axum", "dep:serde_json", "dep:tower"]
fs-store = []
http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
[dependencies]
async-trait.workspace = true
axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
decodal.workspace = true
manifest.workspace = true
protocol.workspace = true
serde = { workspace = true, features = ["derive"] }
session-store.workspace = true
sha2.workspace = true
serde_json = { workspace = true, optional = true }
serde_json.workspace = true
reqwest = { version = "0.13", optional = true, default-features = false, features = ["json", "rustls"] }
tar.workspace = true
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
tower = { workspace = true, features = ["util"], optional = true }

View File

@ -2,6 +2,7 @@ use crate::execution::WorkerExecutionStatus;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::interaction::WorkerInput;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Profile selector boundary. This is a selector, not a resolved config bundle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -25,21 +26,135 @@ pub struct ConfigBundleRef {
pub digest: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySelector(pub String);
impl From<&str> for RepositorySelector {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl From<String> for RepositorySelector {
fn from(value: String) -> Self {
Self(value)
}
}
impl AsRef<str> for RepositorySelector {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for RepositorySelector {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRepository {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<RepositorySelector>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaterializerKind {
#[default]
LocalGitWorktree,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DirtyStatePolicy {
#[default]
CleanPointOnly,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository,
#[serde(default)]
pub materializer: MaterializerKind,
#[serde(default)]
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryClaim {
pub working_directory_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative_cwd: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryStatusKind {
Active,
Removed,
CleanupPending,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryCleanupTarget {
pub kind: String,
pub working_directory_id: String,
pub repository_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectorySummary {
pub working_directory_id: String,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_selector: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_policy: Option<String>,
pub status: WorkingDirectoryStatusKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryStatus {
pub summary: WorkingDirectorySummary,
}
/// Canonical Runtime Worker creation request.
///
/// Browser/product launch semantics are resolved by a backend before this
/// request is built. The request contains only durable Runtime identity inputs:
/// a backend-decided profile selector, a previously synced ConfigBundle identity,
/// and optional initial user input that is committed in the same transaction as
/// Worker catalog/transcript persistence. It carries no cwd/workspace path, tool
/// scope, credential, socket/session path, raw config body, or execution binding
/// internals.
/// optional initial user input that is committed in the same transaction as
/// Worker catalog/transcript persistence, and an optional working directory
/// request that preserves RepositoryPoint-style semantics for runtime-side
/// materialization. Browser-facing status for materialized working directories is
/// summarized without exposing raw host paths.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateWorkerRequest {
pub profile: ProfileSelector,
pub config_bundle: ConfigBundleRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<WorkerInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_request: Option<WorkingDirectoryRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryClaim>,
}
/// Worker lifecycle status for the in-memory embedded runtime.

View File

@ -1,5 +1,10 @@
use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::error::RuntimeError;
use crate::profile_archive::{
ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef,
VerifiedProfileSourceArchive,
};
use crate::resource::{BackendResourceHandle, validate_resource_handle_text};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@ -18,6 +23,10 @@ pub struct ConfigBundle {
pub profiles: Vec<ConfigProfileDescriptor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declarations: Vec<ConfigDeclaration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive: Option<ProfileSourceArchive>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive_handle: Option<BackendResourceHandle>,
}
impl ConfigBundle {
@ -60,6 +69,37 @@ impl ConfigBundle {
));
}
if let Some(archive) = &self.profile_source_archive {
lines.push(format!(
"profile_archive\0{}\0{}\0{}",
archive.reference.id, archive.reference.digest, archive.reference.size_bytes
));
for (selector, path) in &archive.reference.source_graph.entrypoints {
lines.push(format!("profile_archive_entrypoint\0{selector}\0{path}"));
}
}
if let Some(handle) = &self.profile_source_archive_handle {
lines.push(format!(
"profile_archive_handle\0{}\0{}\0{}\0{}\0{}",
handle.workspace_id,
handle.resource_id,
handle.digest,
handle.revision,
handle.max_bytes
));
for (selector, path) in handle
.profile_source_graph
.as_ref()
.map(|graph| &graph.entrypoints)
.into_iter()
.flatten()
{
lines.push(format!(
"profile_archive_handle_entrypoint\0{selector}\0{path}"
));
}
}
lines.sort();
let mut hasher = Sha256::new();
for line in lines {
@ -86,6 +126,15 @@ impl ConfigBundle {
provenance: self.metadata.provenance.clone(),
profile_count: self.profiles.len(),
declaration_count: self.declarations.len(),
profile_source_archive: self
.profile_source_archive
.as_ref()
.map(|archive| archive.reference.source_graph.clone())
.or_else(|| {
self.profile_source_archive_handle
.as_ref()
.and_then(|handle| handle.profile_source_graph.clone())
}),
}
}
@ -164,6 +213,8 @@ pub struct ConfigBundleSummary {
pub provenance: ConfigBundleProvenance,
pub profile_count: usize,
pub declaration_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive: Option<crate::profile_archive::ProfileSourceGraphSummary>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -222,6 +273,43 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
}
validate_declaration_reference(&bundle.metadata.id, declaration)?;
}
if let Some(archive) = &bundle.profile_source_archive {
validate_profile_source_archive_ref(&archive.reference).map_err(|err| {
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
})?;
archive.verify().map_err(|err| {
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
})?;
}
if let Some(handle) = &bundle.profile_source_archive_handle {
for (label, value) in [
("resource handle workspace id", handle.workspace_id.as_str()),
("resource handle resource id", handle.resource_id.as_str()),
("resource handle digest", handle.digest.as_str()),
("resource handle nonce", handle.nonce.as_str()),
("resource handle revision", handle.revision.as_str()),
("resource handle content type", handle.content_type.as_str()),
(
"resource handle audit correlation id",
handle.audit_correlation_id.as_str(),
),
] {
validate_resource_handle_text(label, value).map_err(RuntimeError::InvalidRequest)?;
}
if !handle.digest.starts_with("sha256:") {
return Err(RuntimeError::InvalidRequest(
"resource handle digest must use sha256:<hex>".to_string(),
));
}
if handle.profile_source_graph.is_none() {
return Err(RuntimeError::InvalidRequest(
"profile source archive resource handle must include source graph summary"
.to_string(),
));
}
}
Ok(())
}
@ -232,6 +320,34 @@ pub(crate) fn validate_config_bundle_ref(reference: &ConfigBundleRef) -> Result<
Ok(())
}
pub fn verified_profile_source_archive(
bundle: &ConfigBundle,
) -> Result<Option<VerifiedProfileSourceArchive>, ProfileArchiveError> {
bundle
.profile_source_archive
.as_ref()
.map(ProfileSourceArchive::verify)
.transpose()
}
fn validate_profile_source_archive_ref(
reference: &ProfileSourceArchiveRef,
) -> Result<(), ProfileArchiveError> {
if reference.id.trim().is_empty() {
return Err(ProfileArchiveError::MissingEntrypoint(
"archive id".to_string(),
));
}
if !reference.digest.starts_with("sha256:") {
return Err(ProfileArchiveError::ArchiveDigestMismatch {
id: reference.id.clone(),
expected: "sha256:<hex>".to_string(),
actual: reference.digest.clone(),
});
}
Ok(())
}
pub(crate) fn validate_profile_selector(
selector: ProfileSelector,
bundle_id: Option<&str>,
@ -468,6 +584,8 @@ mod tests {
name: "credential".to_string(),
reference: reference.to_string(),
}],
profile_source_archive: None,
profile_source_archive_handle: None,
}
.with_computed_digest()
}
@ -499,6 +617,53 @@ mod tests {
validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap();
}
#[test]
fn bundle_summary_redacts_runtime_internal_resource_handle() {
let mut bundle = bundle_with_declaration("secret:github-token");
let source_graph = crate::profile_archive::ProfileSourceGraphSummary {
entrypoints: std::collections::BTreeMap::from([(
"default".to_string(),
"profiles/default.dcdl".to_string(),
)]),
source_count: 1,
import_count: 0,
total_source_bytes: 16,
};
bundle.profile_source_archive_handle = Some(crate::resource::BackendResourceHandle {
kind: crate::resource::BackendResourceKind::ProfileSourceArchive,
workspace_id: "workspace-1".to_string(),
scope_id: Some("scope-1".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: Some("worker-1".to_string()),
resource_id: "profile-source-archive:test".to_string(),
digest: "sha256:0123456789abcdef".to_string(),
operation: crate::resource::BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-not-for-browser".to_string(),
revision: "rev-1".to_string(),
generation: Some(1),
max_bytes: 128,
content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: "audit-1".to_string(),
profile_source_graph: Some(source_graph),
});
let rendered = serde_json::to_string(&bundle.summary()).unwrap();
assert!(rendered.contains("profile_source_archive"));
for forbidden in [
"nonce-not-for-browser",
"profile-source-archive:test",
"sha256:0123456789abcdef",
"runtime-1",
"worker-1",
] {
assert!(
!rendered.contains(forbidden),
"leaked {forbidden}: {rendered}"
);
}
}
#[test]
fn rejects_unsafe_bundle_ids_and_refs() {
for id in ["bundle/1", "bundle?x", "bundle&x", "bundle#x", " bundle"] {

View File

@ -1,9 +1,11 @@
use crate::catalog::CreateWorkerRequest;
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError;
use crate::identity::WorkerRef;
use crate::interaction::WorkerInput;
#[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationEvent;
use crate::working_directory::WorkingDirectoryBinding;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::Arc;
@ -153,6 +155,8 @@ pub struct WorkerExecutionStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<WorkerExecutionBindingIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<WorkerExecutionResult>,
}
@ -166,6 +170,7 @@ impl WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state,
binding: None,
working_directory: None,
last_result: None,
}
}
@ -181,6 +186,11 @@ impl WorkerExecutionStatus {
self
}
pub fn with_working_directory(mut self, status: WorkingDirectoryStatus) -> Self {
self.working_directory = Some(status);
self
}
pub fn with_result(mut self, result: WorkerExecutionResult) -> Self {
self.run_state = result.run_state;
self.last_result = Some(result);
@ -277,6 +287,8 @@ pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef,
pub request: CreateWorkerRequest,
pub context: WorkerExecutionContext,
pub working_directory: Option<WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>,
}
/// Result of backend Worker spawn/initialization.
@ -285,6 +297,7 @@ pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
working_directory: Option<WorkingDirectoryStatus>,
},
Rejected(WorkerExecutionResult),
Errored(WorkerExecutionResult),

View File

@ -849,6 +849,8 @@ mod tests {
label: Some("test".to_string()),
}],
declarations: Vec::new(),
profile_source_archive: None,
profile_source_archive_handle: None,
}
.with_computed_digest()
}
@ -863,6 +865,8 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
working_directory_request: None,
working_directory: None,
}
}
@ -877,6 +881,10 @@ mod tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
}
@ -1092,6 +1100,10 @@ mod ws_tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
}
@ -1125,6 +1137,8 @@ mod ws_tests {
label: Some("ws".to_string()),
}],
declarations: Vec::new(),
profile_source_archive: None,
profile_source_archive_handle: None,
}
.with_computed_digest()
}
@ -1138,6 +1152,8 @@ mod ws_tests {
digest: bundle.metadata.digest,
},
initial_input: None,
working_directory_request: None,
working_directory: None,
}
}

View File

@ -19,8 +19,11 @@ pub mod identity;
pub mod interaction;
pub mod management;
pub mod observation;
pub mod profile_archive;
pub mod resource;
mod runtime;
pub mod worker_backend;
pub mod working_directory;
#[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};

View File

@ -20,6 +20,7 @@ use worker_runtime::http_server::{
};
use worker_runtime::identity::RuntimeId;
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode {
@ -77,11 +78,25 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
if let Some(runtime_base_dir) = config.worker_runtime_base_dir.clone() {
factory = factory.with_runtime_base_dir(runtime_base_dir);
}
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
factory = factory.with_resource_client(Arc::new(
worker_runtime::resource::HttpBackendResourceClient::new(
endpoint,
config.backend_resource_token.clone(),
),
));
}
if let Some(profile) = config.profile.clone() {
factory = factory.with_profile(profile);
}
let backend =
Arc::new(WorkerRuntimeExecutionBackend::new(factory).map_err(ProcessError::WorkerAdapter)?);
let working_directory_root = working_directory_runtime_root(config);
let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)?
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
working_directory_root,
)),
);
match &config.http.store {
RuntimeHttpStoreSelection::Memory => {
@ -102,6 +117,16 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}
}
fn working_directory_runtime_root(config: &ProcessConfig) -> PathBuf {
match &config.http.store {
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
_ => config
.worker_runtime_base_dir
.clone()
.unwrap_or_else(|| env::temp_dir().join("yoi-worker-runtime")),
}
}
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
RuntimeOptions {
runtime_id: config.runtime_id.clone(),
@ -156,6 +181,13 @@ where
config.worker_runtime_base_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--backend-resource-endpoint" => {
config.backend_resource_endpoint =
Some(take_value(&flag, inline_value, &mut args)?);
}
"--backend-resource-token" => {
config.backend_resource_token = Some(take_value(&flag, inline_value, &mut args)?);
}
"--profile" => {
config.profile = Some(take_value(&flag, inline_value, &mut args)?);
}
@ -215,12 +247,33 @@ where
}
apply_store_selection(&mut config.http, store)?;
config.workspace_root = normalize_workspace_path(config.workspace_root)?;
if config.cwd.as_os_str().is_empty() {
config.cwd = config.workspace_root.clone();
} else {
config.cwd = normalize_child_path(&config.workspace_root, config.cwd);
}
Ok(Some(config))
}
fn normalize_workspace_path(path: PathBuf) -> Result<PathBuf, ProcessError> {
let absolute = if path.is_absolute() {
path
} else {
env::current_dir()?.join(path)
};
Ok(absolute.canonicalize().unwrap_or(absolute))
}
fn normalize_child_path(base: &std::path::Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
path.canonicalize().unwrap_or(path)
} else {
let absolute = base.join(path);
absolute.canonicalize().unwrap_or(absolute)
}
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), ProcessError> {
if !arg.starts_with('-') {
return Err(ProcessError::usage(format!(
@ -279,6 +332,8 @@ struct ProcessConfig {
worker_store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
worker_runtime_base_dir: Option<PathBuf>,
backend_resource_endpoint: Option<String>,
backend_resource_token: Option<String>,
profile: Option<String>,
}
@ -292,6 +347,8 @@ impl ProcessConfig {
worker_store_dir: None,
worker_metadata_dir: None,
worker_runtime_base_dir: None,
backend_resource_endpoint: None,
backend_resource_token: None,
profile: None,
})
}
@ -367,6 +424,8 @@ Options:\n\
--worker-store-dir <PATH> Worker session store directory\n\
--worker-metadata-dir <PATH> Worker metadata directory\n\
--worker-runtime-base-dir <PATH> Worker controller runtime directory\n\
--backend-resource-endpoint <URL> Internal Backend resource fetch endpoint for resource handles\n\
--backend-resource-token <TOKEN> Optional bearer token for the Backend resource fetch endpoint\n\
--store <memory|fs> Runtime catalog store selection (default: memory)\n\
--fs-root <PATH> Runtime catalog filesystem store root\n\
--local-token <TOKEN> Minimal local bearer token placeholder\n\
@ -395,6 +454,17 @@ mod tests {
);
}
#[test]
fn normalizes_relative_workspace_for_worker_spawn() {
let current_dir = env::current_dir().unwrap().canonicalize().unwrap();
let config = parse_args(["--workspace", ".", "--cwd", "."])
.unwrap()
.unwrap();
assert_eq!(config.workspace_root, current_dir);
assert_eq!(config.cwd, current_dir);
}
#[test]
fn parses_memory_runtime_process_config() {
let config = parse_args([

View File

@ -0,0 +1,554 @@
use decodal::{Engine, LoadedSource, SourceLoader};
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
use std::path::{Component, Path, PathBuf};
use tar::{Archive, Builder, Header};
const MANIFEST_PATH: &str = "manifest.json";
const MAX_SOURCES: usize = 64;
const MAX_SOURCE_BYTES: u64 = 256 * 1024;
const MAX_TOTAL_BYTES: u64 = 1024 * 1024;
const MAX_PATH_DEPTH: usize = 8;
#[derive(Debug, thiserror::Error)]
pub enum ProfileArchiveError {
#[error("profile source archive has unsupported schema version {0}")]
UnsupportedSchema(u32),
#[error("profile source archive path must be relative and confined: {0}")]
UnsafePath(String),
#[error("profile source archive contains unsupported entry type at {0}")]
UnsupportedEntry(String),
#[error("profile source archive limit exceeded: {0}")]
LimitExceeded(&'static str),
#[error("profile source archive missing manifest.json")]
MissingManifest,
#[error("profile source archive missing source {0}")]
MissingSource(String),
#[error(
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
)]
SourceDigestMismatch {
path: String,
expected: String,
actual: String,
},
#[error("profile source archive digest mismatch for {id}: expected {expected}, got {actual}")]
ArchiveDigestMismatch {
id: String,
expected: String,
actual: String,
},
#[error("profile source archive has unsupported source kind {kind} at {path}")]
UnsupportedSource { path: String, kind: String },
#[error("profile source archive has no entrypoint for selector {0}")]
MissingEntrypoint(String),
#[error("profile source archive import is not declared in manifest import map: {0}")]
ImportNotDeclared(String),
#[error("failed to read profile source archive: {0}")]
Io(String),
#[error("failed to parse profile source archive manifest: {0}")]
Json(String),
#[error("failed to evaluate Decodal profile source {path}: {message}")]
Decodal { path: String, message: String },
#[error("failed to resolve archive profile artifact {profile_source}: {message}")]
Profile {
profile_source: String,
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveRef {
pub id: String,
pub digest: String,
pub size_bytes: u64,
pub source_graph: ProfileSourceGraphSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceGraphSummary {
pub source_count: usize,
pub total_source_bytes: u64,
pub entrypoints: BTreeMap<String, String>,
pub import_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchive {
pub reference: ProfileSourceArchiveRef,
pub content: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveManifest {
pub schema_version: u32,
pub id: String,
pub entrypoints: BTreeMap<String, String>,
pub imports: BTreeMap<String, String>,
pub sources: Vec<ProfileSourceArchiveSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveSource {
pub path: String,
pub kind: String,
pub digest: String,
pub size_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveInput {
pub id: String,
pub entrypoints: BTreeMap<String, String>,
pub imports: BTreeMap<String, String>,
pub sources: BTreeMap<String, String>,
}
impl ProfileSourceArchive {
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
if input.sources.len() > MAX_SOURCES {
return Err(ProfileArchiveError::LimitExceeded("source count"));
}
let mut total_source_bytes = 0u64;
let mut source_meta = Vec::new();
for (path, source) in &input.sources {
validate_archive_path(path)?;
let size = source.as_bytes().len() as u64;
if size > MAX_SOURCE_BYTES {
return Err(ProfileArchiveError::LimitExceeded("source bytes"));
}
total_source_bytes = total_source_bytes
.checked_add(size)
.ok_or(ProfileArchiveError::LimitExceeded("total source bytes"))?;
if total_source_bytes > MAX_TOTAL_BYTES {
return Err(ProfileArchiveError::LimitExceeded("total source bytes"));
}
source_meta.push(ProfileSourceArchiveSource {
path: path.clone(),
kind: "decodal".to_string(),
digest: sha256_hex(source.as_bytes()),
size_bytes: size,
});
}
for path in input.entrypoints.values().chain(input.imports.values()) {
validate_archive_path(path)?;
if !input.sources.contains_key(path) {
return Err(ProfileArchiveError::MissingSource(path.clone()));
}
}
let manifest = ProfileSourceArchiveManifest {
schema_version: 1,
id: input.id.clone(),
entrypoints: input.entrypoints.clone(),
imports: input.imports.clone(),
sources: source_meta,
};
let manifest_json = serde_json::to_vec_pretty(&manifest)
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
let mut bytes = Vec::new();
{
let mut builder = Builder::new(&mut bytes);
append_bytes(&mut builder, MANIFEST_PATH, &manifest_json)?;
for (path, source) in &input.sources {
append_bytes(&mut builder, path, source.as_bytes())?;
}
builder
.finish()
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
}
let digest = sha256_hex(&bytes);
let reference = ProfileSourceArchiveRef {
id: input.id,
digest,
size_bytes: bytes.len() as u64,
source_graph: ProfileSourceGraphSummary {
source_count: input.sources.len(),
total_source_bytes,
entrypoints: input.entrypoints,
import_count: manifest.imports.len(),
},
};
Ok(Self {
reference,
content: bytes,
})
}
pub fn verify(&self) -> Result<VerifiedProfileSourceArchive, ProfileArchiveError> {
let digest = sha256_hex(&self.content);
if digest != self.reference.digest {
return Err(ProfileArchiveError::ArchiveDigestMismatch {
id: self.reference.id.clone(),
expected: self.reference.digest.clone(),
actual: digest,
});
}
VerifiedProfileSourceArchive::from_bytes(self.reference.clone(), &self.content)
}
}
#[derive(Debug, Clone)]
pub struct VerifiedProfileSourceArchive {
reference: ProfileSourceArchiveRef,
manifest: ProfileSourceArchiveManifest,
sources: BTreeMap<String, String>,
}
impl VerifiedProfileSourceArchive {
pub fn from_bytes(
reference: ProfileSourceArchiveRef,
bytes: &[u8],
) -> Result<Self, ProfileArchiveError> {
if bytes.len() as u64 > MAX_TOTAL_BYTES + 64 * 1024 {
return Err(ProfileArchiveError::LimitExceeded("archive bytes"));
}
let mut archive = Archive::new(Cursor::new(bytes));
let mut manifest: Option<ProfileSourceArchiveManifest> = None;
let mut entries = BTreeMap::new();
for entry in archive
.entries()
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?
{
let mut entry = entry.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
let path = entry
.path()
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?
.to_string_lossy()
.to_string();
validate_archive_path(&path)?;
if !entry.header().entry_type().is_file() {
return Err(ProfileArchiveError::UnsupportedEntry(path));
}
let size = entry.size();
if size > MAX_SOURCE_BYTES && path != MANIFEST_PATH {
return Err(ProfileArchiveError::LimitExceeded("source bytes"));
}
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
if path == MANIFEST_PATH {
manifest = Some(
serde_json::from_slice(&data)
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?,
);
} else {
let source = String::from_utf8(data)
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
entries.insert(path, source);
}
if entries.len() > MAX_SOURCES {
return Err(ProfileArchiveError::LimitExceeded("source count"));
}
}
let manifest = manifest.ok_or(ProfileArchiveError::MissingManifest)?;
if manifest.schema_version != 1 {
return Err(ProfileArchiveError::UnsupportedSchema(
manifest.schema_version,
));
}
if manifest.id != reference.id {
return Err(ProfileArchiveError::ArchiveDigestMismatch {
id: reference.id,
expected: "matching archive id".to_string(),
actual: manifest.id,
});
}
let mut total = 0u64;
for source in &manifest.sources {
validate_archive_path(&source.path)?;
if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.kind.clone(),
});
}
let content = entries
.get(&source.path)
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
let digest = sha256_hex(content.as_bytes());
if digest != source.digest {
return Err(ProfileArchiveError::SourceDigestMismatch {
path: source.path.clone(),
expected: source.digest.clone(),
actual: digest,
});
}
let size = content.as_bytes().len() as u64;
if size != source.size_bytes {
return Err(ProfileArchiveError::LimitExceeded(
"source byte metadata mismatch",
));
}
total += size;
}
if total != reference.source_graph.total_source_bytes
|| manifest.sources.len() != reference.source_graph.source_count
{
return Err(ProfileArchiveError::LimitExceeded(
"source graph metadata mismatch",
));
}
for path in manifest
.entrypoints
.values()
.chain(manifest.imports.values())
{
validate_archive_path(path)?;
if !entries.contains_key(path) {
return Err(ProfileArchiveError::MissingSource(path.clone()));
}
}
Ok(Self {
reference,
manifest,
sources: entries,
})
}
pub fn reference(&self) -> &ProfileSourceArchiveRef {
&self.reference
}
pub fn resolve_profile(
&self,
selector: &str,
worker_root: &Path,
worker_name: &str,
) -> Result<WorkerManifest, ProfileArchiveError> {
let path = self
.manifest
.entrypoints
.get(selector)
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
let source = self
.sources
.get(path)
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
let module = engine.add_root_source(path, path, source).map_err(|err| {
ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
}
})?;
let value = engine
.eval_module(module)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
let data = engine
.materialize(&value)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
let json = decodal_data_to_json(&data);
let resolved = resolve_profile_artifact_value(
json,
ProfileSource::Archive {
archive_id: self.reference.id.clone(),
source: path.clone(),
},
worker_root,
worker_name,
)
.map_err(|err| ProfileArchiveError::Profile {
profile_source: path.clone(),
message: err.to_string(),
})?;
Ok(resolved.manifest)
}
}
pub struct ArchiveSourceLoader<'a> {
archive: &'a VerifiedProfileSourceArchive,
}
impl<'a> ArchiveSourceLoader<'a> {
pub fn new(archive: &'a VerifiedProfileSourceArchive) -> Self {
Self { archive }
}
}
impl SourceLoader for ArchiveSourceLoader<'_> {
fn load(
&mut self,
_current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedSource> {
let path = self
.archive
.manifest
.imports
.get(specifier)
.ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("archive import not declared: {specifier}"),
)
})?;
let source = self.archive.sources.get(path).ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("archive source missing: {path}"),
)
})?;
Ok(LoadedSource {
key: path.clone(),
name: path.clone(),
source: source.clone(),
})
}
}
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
match data {
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
decodal::Data::Int(value) => serde_json::Value::Number(serde_json::Number::from(*value)),
decodal::Data::Float(value) => serde_json::Number::from_f64(*value)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
decodal::Data::String(value) => serde_json::Value::String(value.clone()),
decodal::Data::Array(values) => {
serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect())
}
decodal::Data::Object(fields) => serde_json::Value::Object(
fields
.iter()
.map(|field| (field.name.clone(), decodal_data_to_json(&field.value)))
.collect(),
),
}
}
fn append_bytes<W: Write>(
builder: &mut Builder<W>,
path: &str,
data: &[u8],
) -> Result<(), ProfileArchiveError> {
validate_archive_path(path)?;
let mut header = Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, path, Cursor::new(data))
.map_err(|err| ProfileArchiveError::Io(err.to_string()))
}
fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> {
if path.is_empty() || path == MANIFEST_PATH {
return if path == MANIFEST_PATH {
Ok(())
} else {
Err(ProfileArchiveError::UnsafePath(path.to_string()))
};
}
let path_ref = Path::new(path);
if path_ref.is_absolute() {
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
}
let mut depth = 0usize;
for component in path_ref.components() {
match component {
Component::Normal(_) => depth += 1,
_ => return Err(ProfileArchiveError::UnsafePath(path.to_string())),
}
}
if depth == 0 || depth > MAX_PATH_DEPTH {
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
}
let normalized = PathBuf::from(path).to_string_lossy().replace('\\', "/");
if normalized != path {
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
}
Ok(())
}
pub fn sha256_hex(data: &[u8]) -> String {
let digest = Sha256::digest(data);
let mut out = String::from("sha256:");
for byte in digest.as_slice() {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_archive() -> ProfileSourceArchive {
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string());
let mut sources = BTreeMap::new();
sources.insert(
"profiles/default.dcdl".to_string(),
r#"{
slug = "default";
description = "Default";
scope = "workspace_read";
}"#
.to_string(),
);
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "builtin".to_string(),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.unwrap()
}
#[test]
fn build_and_verify_archive() {
let archive = sample_archive();
let verified = archive.verify().unwrap();
assert_eq!(verified.reference().source_graph.source_count, 1);
}
#[test]
fn rejects_digest_mismatch() {
let mut archive = sample_archive();
archive.reference.digest = "sha256:bad".to_string();
assert!(archive.verify().is_err());
}
#[test]
fn resolves_profile_from_archive_without_filesystem_loader() {
let archive = sample_archive();
let verified = archive.verify().unwrap();
let root = tempfile::tempdir().unwrap();
let manifest = verified
.resolve_profile("default", root.path(), "archive-worker")
.unwrap();
assert_eq!(manifest.worker.name, "archive-worker");
}
#[test]
fn unknown_selector_does_not_fallback_to_default() {
let archive = sample_archive();
let verified = archive.verify().unwrap();
let root = tempfile::tempdir().unwrap();
assert!(matches!(
verified.resolve_profile("missing", root.path(), "archive-worker"),
Err(ProfileArchiveError::MissingEntrypoint(selector)) if selector == "missing"
));
}
#[test]
fn archive_loader_rejects_undeclared_import() {
let archive = sample_archive();
let verified = archive.verify().unwrap();
let mut loader = ArchiveSourceLoader::new(&verified);
assert!(loader.load(None, "missing").is_err());
}
}

View File

@ -0,0 +1,336 @@
use crate::identity::{RuntimeId, WorkerId};
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendResourceKind {
ProfileSourceArchive,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendResourceOperation {
FetchArchive,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendResourceHandle {
pub kind: BackendResourceKind,
pub workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
pub resource_id: String,
pub digest: String,
pub operation: BackendResourceOperation,
pub expires_at_unix_seconds: i64,
pub nonce: String,
pub revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
pub max_bytes: u64,
pub content_type: String,
pub redaction: ResourceRedactionPolicy,
pub audit_correlation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_graph: Option<crate::profile_archive::ProfileSourceGraphSummary>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceRedactionPolicy {
RuntimeInternalOnly,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendResourceFetchRequest {
pub handle: BackendResourceHandle,
pub runtime_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
pub audit_correlation_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendResourceFetchResponse {
pub kind: BackendResourceKind,
pub resource_id: String,
pub digest: String,
pub content_type: String,
pub bytes: Vec<u8>,
pub audit_correlation_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum BackendResourceError {
#[error("backend resource handle is expired")]
Expired,
#[error("backend resource handle is unauthorized: {message}")]
Unauthorized { message: String },
#[error("backend resource kind is unsupported")]
UnsupportedKind,
#[error("backend resource is missing")]
MissingResource,
#[error("backend resource digest mismatch: expected {expected}, got {actual}")]
DigestMismatch { expected: String, actual: String },
#[error("backend resource response is oversized: limit {max_bytes}, actual {actual_bytes}")]
Oversized { max_bytes: u64, actual_bytes: u64 },
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
ContentTypeMismatch { expected: String, actual: String },
#[error("backend resource transport failed: {message}")]
Transport { message: String },
#[error("backend resource response is invalid: {message}")]
InvalidResponse { message: String },
}
#[async_trait]
pub trait BackendResourceClient: Send + Sync + 'static {
async fn fetch_resource(
&self,
request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError>;
}
#[cfg(feature = "http-server")]
#[derive(Clone, Debug)]
pub struct HttpBackendResourceClient {
endpoint: String,
bearer_token: Option<String>,
client: reqwest::Client,
}
#[cfg(feature = "http-server")]
impl HttpBackendResourceClient {
pub fn new(endpoint: impl Into<String>, bearer_token: Option<String>) -> Self {
Self {
endpoint: endpoint.into(),
bearer_token,
client: reqwest::Client::new(),
}
}
}
#[cfg(feature = "http-server")]
#[async_trait]
impl BackendResourceClient for HttpBackendResourceClient {
async fn fetch_resource(
&self,
request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
let builder = self.client.post(&self.endpoint).json(&request);
let builder = if let Some(token) = self.bearer_token.as_deref() {
builder.bearer_auth(token)
} else {
builder
};
let response = builder
.send()
.await
.map_err(|err| BackendResourceError::Transport {
message: err.to_string(),
})?;
if response.status().is_success() {
response
.json::<BackendResourceFetchResponse>()
.await
.map_err(|err| BackendResourceError::InvalidResponse {
message: err.to_string(),
})
} else {
let status = response.status();
match response.json::<BackendResourceError>().await {
Ok(error) => Err(error),
Err(err) => Err(BackendResourceError::Transport {
message: format!("backend resource fetch failed with HTTP {status}: {err}"),
}),
}
}
}
}
#[derive(Default, Debug)]
pub struct ProfileSourceArchiveCache {
archives: Mutex<HashMap<String, ProfileSourceArchive>>,
}
impl ProfileSourceArchiveCache {
pub fn get(&self, digest: &str) -> Option<ProfileSourceArchive> {
self.archives.lock().ok()?.get(digest).cloned()
}
pub fn insert(&self, archive: ProfileSourceArchive) {
if let Ok(mut archives) = self.archives.lock() {
archives.insert(archive.reference.digest.clone(), archive);
}
}
}
pub fn build_profile_source_archive_fetch_request(
handle: BackendResourceHandle,
runtime_id: &RuntimeId,
worker_id: Option<&WorkerId>,
) -> BackendResourceFetchRequest {
let audit_correlation_id = handle.audit_correlation_id.clone();
BackendResourceFetchRequest {
handle,
runtime_id: runtime_id.as_str().to_string(),
worker_id: worker_id.map(|id| id.as_str().to_string()),
audit_correlation_id,
}
}
pub fn profile_source_archive_from_response(
handle: &BackendResourceHandle,
response: BackendResourceFetchResponse,
) -> Result<ProfileSourceArchive, BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive
|| response.kind != BackendResourceKind::ProfileSourceArchive
{
return Err(BackendResourceError::UnsupportedKind);
}
if handle.operation != BackendResourceOperation::FetchArchive {
return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_archive".to_string(),
});
}
if response.content_type != handle.content_type {
return Err(BackendResourceError::ContentTypeMismatch {
expected: handle.content_type.clone(),
actual: response.content_type,
});
}
let actual_bytes = response.bytes.len() as u64;
if actual_bytes > handle.max_bytes {
return Err(BackendResourceError::Oversized {
max_bytes: handle.max_bytes,
actual_bytes,
});
}
let actual_digest = sha256_hex(&response.bytes);
if actual_digest != handle.digest || response.digest != handle.digest {
return Err(BackendResourceError::DigestMismatch {
expected: handle.digest.clone(),
actual: if response.digest != handle.digest {
response.digest
} else {
actual_digest
},
});
}
Ok(ProfileSourceArchive {
reference: ProfileSourceArchiveRef {
id: handle.resource_id.clone(),
digest: handle.digest.clone(),
size_bytes: actual_bytes,
source_graph: handle.profile_source_graph.clone().ok_or_else(|| {
BackendResourceError::InvalidResponse {
message: "profile source archive handle omitted source graph summary"
.to_string(),
}
})?,
},
content: response.bytes,
})
}
pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), String> {
if value.trim().is_empty() {
return Err(format!("{label} must not be empty"));
}
if value.len() > 256 || value.contains('\0') || value.contains('\n') || value.contains('\r') {
return Err(format!("{label} contains unsupported boundary text"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile_archive::ProfileSourceGraphSummary;
use std::collections::BTreeMap;
fn graph() -> ProfileSourceGraphSummary {
ProfileSourceGraphSummary {
source_count: 1,
total_source_bytes: 4,
entrypoints: BTreeMap::from([(
"default".to_string(),
"profiles/default.dcdl".to_string(),
)]),
import_count: 0,
}
}
fn handle_for(bytes: &[u8]) -> BackendResourceHandle {
BackendResourceHandle {
kind: 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: "profile-source-archive:test".to_string(),
digest: sha256_hex(bytes),
operation: BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: 4_102_444_800,
nonce: "nonce-test".to_string(),
revision: sha256_hex(bytes),
generation: Some(1),
max_bytes: 1024,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: "audit-test".to_string(),
profile_source_graph: Some(graph()),
}
}
#[test]
fn response_verification_detects_digest_mismatch() {
let bytes = b"archive-bytes";
let handle = handle_for(bytes);
let error = profile_source_archive_from_response(
&handle,
BackendResourceFetchResponse {
kind: BackendResourceKind::ProfileSourceArchive,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
bytes: b"tampered-bytes".to_vec(),
audit_correlation_id: handle.audit_correlation_id.clone(),
},
)
.unwrap_err();
assert!(matches!(error, BackendResourceError::DigestMismatch { .. }));
}
#[test]
fn response_verification_rejects_oversized_bytes() {
let bytes = b"archive-bytes";
let mut handle = handle_for(bytes);
handle.max_bytes = 2;
let error = profile_source_archive_from_response(
&handle,
BackendResourceFetchResponse {
kind: BackendResourceKind::ProfileSourceArchive,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
bytes: bytes.to_vec(),
audit_correlation_id: handle.audit_correlation_id.clone(),
},
)
.unwrap_err();
assert!(matches!(error, BackendResourceError::Oversized { .. }));
}
}

View File

@ -1,6 +1,6 @@
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
WorkerStatus, WorkerSummary,
WorkerStatus, WorkerSummary, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus,
};
use crate::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
@ -244,6 +244,13 @@ impl Runtime {
state.ensure_running()?;
validate_create_worker_request(&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(|| {
RuntimeError::ExecutionBackendUnavailable {
message: "worker creation requires an execution backend".to_string(),
@ -288,13 +295,19 @@ impl Runtime {
worker_ref: worker_ref.clone(),
request,
context: self.execution_context(worker_ref.clone()),
working_directory: None,
config_bundle: Some(config_bundle),
};
(backend, worker_ref, spawn_request)
};
let spawn_result = backend.spawn_worker(spawn_request);
let (handle, run_state) = match spawn_result {
WorkerExecutionSpawnResult::Connected { handle, run_state } => (handle, run_state),
let (handle, run_state, working_directory) = match spawn_result {
WorkerExecutionSpawnResult::Connected {
handle,
run_state,
working_directory,
} => (handle, run_state, working_directory),
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => {
self.rollback_failed_create(&worker_ref)?;
@ -328,6 +341,7 @@ impl Runtime {
&worker_ref,
handle,
WorkerExecutionRunState::Busy,
working_directory,
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
@ -338,6 +352,7 @@ impl Runtime {
&worker_ref,
handle,
run_state,
working_directory,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn, run_state),
)
}
@ -433,6 +448,7 @@ impl Runtime {
backend: WorkerExecutionBackendKind::Connected,
run_state: dispatch_result.run_state,
binding: worker.execution.binding.clone(),
working_directory: worker.execution.working_directory.clone(),
last_result: Some(dispatch_result),
};
worker.transcript.push(TranscriptEntry {
@ -482,6 +498,7 @@ impl Runtime {
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
working_directory: Option<CatalogWorkingDirectoryStatus>,
result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> {
let mut state = self.lock()?;
@ -490,9 +507,11 @@ impl Runtime {
let binding = WorkerExecutionBindingIdentity::from_handle(&handle);
let worker = state.worker_mut(worker_ref)?;
worker.execution_handle = Some(handle);
worker.execution = WorkerExecutionStatus::connected(run_state)
.with_binding(binding)
.with_result(result);
let mut execution = WorkerExecutionStatus::connected(run_state).with_binding(binding);
if let Some(status) = working_directory {
execution = execution.with_working_directory(status);
}
worker.execution = execution.with_result(result);
worker.detail(&runtime_id)
};
state.persist_runtime_snapshot()?;
@ -526,6 +545,7 @@ impl Runtime {
backend: WorkerExecutionBackendKind::Connected,
run_state: result.run_state,
binding: worker.execution.binding.clone(),
working_directory: worker.execution.working_directory.clone(),
last_result: Some(result),
};
state.persist_worker(&worker_ref.worker_id)?;
@ -1580,6 +1600,8 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
working_directory_request: None,
working_directory: None,
}
}
@ -1605,6 +1627,8 @@ mod tests {
name: "read".to_string(),
reference: "capability:read".to_string(),
}],
profile_source_archive: None,
profile_source_archive_handle: None,
}
.with_computed_digest()
}
@ -1645,6 +1669,10 @@ mod tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
}
@ -1916,6 +1944,10 @@ mod tests {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
}

View File

@ -14,11 +14,17 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use crate::config_bundle::verified_profile_source_archive;
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::interaction::{WorkerInput, WorkerInputKind};
use crate::resource::{
BackendResourceClient, BackendResourceError, ProfileSourceArchiveCache,
build_profile_source_archive_fetch_request, profile_source_archive_from_response,
};
use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryMaterializer};
use async_trait::async_trait;
use manifest::paths;
use protocol::{Method, Segment, WorkerStatus};
@ -44,26 +50,30 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
/// Production factory that resolves a normal Worker profile and spawns it under
/// `WorkerController`.
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct ProfileRuntimeWorkerFactory {
workspace_root: PathBuf,
profile_base_dir: PathBuf,
cwd: PathBuf,
store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
profile: Option<String>,
runtime_base_dir: Option<PathBuf>,
resource_client: Option<Arc<dyn BackendResourceClient>>,
profile_archive_cache: Arc<ProfileSourceArchiveCache>,
}
impl ProfileRuntimeWorkerFactory {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
let workspace_root = workspace_root.into();
pub fn new(profile_base_dir: impl Into<PathBuf>) -> Self {
let profile_base_dir = profile_base_dir.into();
Self {
cwd: workspace_root.clone(),
workspace_root,
cwd: profile_base_dir.clone(),
profile_base_dir,
store_dir: None,
worker_metadata_dir: None,
profile: None,
runtime_base_dir: None,
resource_client: None,
profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()),
}
}
@ -94,6 +104,11 @@ impl ProfileRuntimeWorkerFactory {
self
}
pub fn with_resource_client(mut self, resource_client: Arc<dyn BackendResourceClient>) -> Self {
self.resource_client = Some(resource_client);
self
}
fn store_dir(&self) -> Result<PathBuf, String> {
self.store_dir
.clone()
@ -150,6 +165,56 @@ impl ProfileRuntimeWorkerFactory {
}
Self::runtime_profile_value(&request.request.profile)
}
async fn resolve_profile_source_archive(
&self,
bundle: &crate::config_bundle::ConfigBundle,
request: &WorkerExecutionSpawnRequest,
) -> Result<crate::profile_archive::VerifiedProfileSourceArchive, String> {
if let Some(archive) = verified_profile_source_archive(bundle)
.map_err(|err| format!("failed to verify profile source archive: {err}"))?
{
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()
.map_err(|err| format!("failed to verify cached profile source archive: {err}"));
}
self.profile_archive_cache.insert(fetched_archive.clone());
fetched_archive
.verify()
.map_err(|err| format!("failed to verify fetched profile source archive: {err}"))
}
}
fn format_backend_resource_error(error: BackendResourceError) -> String {
format!("backend resource fetch failed: {error}")
}
#[async_trait]
@ -160,11 +225,39 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
) -> Result<WorkerHandle, String> {
let worker_name = Self::runtime_worker_name(&request);
let profile = self.runtime_profile(&request);
let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest(
profile.as_deref(),
&self.workspace_root,
&worker_name,
)?;
let worker_root = request
.working_directory
.as_ref()
.map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.profile_base_dir.clone());
let cwd = request
.working_directory
.as_ref()
.map(|binding| binding.cwd().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
let (mut manifest, loader) = if let Some(bundle) = request.config_bundle.as_ref() {
let selector = profile.as_deref().unwrap_or("builtin:default");
let archive = self
.resolve_profile_source_archive(bundle, &request)
.await?;
let manifest = archive
.resolve_profile(selector, &worker_root, &worker_name)
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
worker::entrypoint::resolve_runtime_profile_manifest_from_manifest(
manifest,
&worker_root,
&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;
let store_dir = self.store_dir()?;
@ -183,15 +276,9 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
})?;
let store = CombinedStore::new(session_store, worker_metadata_store);
let worker = Worker::from_manifest_with_context(
manifest,
store,
loader,
self.workspace_root.clone(),
self.cwd.clone(),
)
.await
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
let worker = Worker::from_manifest_with_context(manifest, store, loader, worker_root, cwd)
.await
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
@ -204,12 +291,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
struct RuntimeWorkerExecution {
handle: WorkerHandle,
busy: Arc<AtomicBool>,
working_directory: Option<WorkingDirectoryBinding>,
}
/// `worker-runtime` execution backend backed by real `worker` crate Workers.
pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
backend_id: String,
factory: Arc<F>,
working_directory_materializer: Option<Arc<dyn WorkingDirectoryMaterializer>>,
runtime: Mutex<Option<Runtime>>,
workers: Mutex<HashMap<crate::identity::WorkerRef, RuntimeWorkerExecution>>,
}
@ -233,6 +322,7 @@ where
Ok(Self {
backend_id: DEFAULT_BACKEND_ID.to_string(),
factory: Arc::new(factory),
working_directory_materializer: None,
runtime: Mutex::new(Some(runtime)),
workers: Mutex::new(HashMap::new()),
})
@ -243,6 +333,14 @@ where
self
}
pub fn with_working_directory_materializer(
mut self,
materializer: impl WorkingDirectoryMaterializer,
) -> Self {
self.working_directory_materializer = Some(Arc::new(materializer));
self
}
fn wait_for_runtime_task<T>(receiver: mpsc::Receiver<Result<T, String>>) -> Result<T, String> {
receiver
.recv_timeout(RUNTIME_TASK_TIMEOUT)
@ -356,6 +454,67 @@ where
));
}
let mut request = request;
let working_directory = match (
request.request.working_directory_request.as_ref(),
request.request.working_directory.as_ref(),
) {
(Some(_), Some(_)) => {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"worker spawn cannot specify both working_directory_request and working_directory",
));
}
(Some(working_directory_request), None) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"working directory materialization requested, but no materializer is configured for this runtime backend",
));
};
match materializer.materialize(&request.worker_ref, working_directory_request) {
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
error.to_string(),
),
);
}
}
}
(None, Some(working_directory)) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"working directory working_directory requested, but no materializer is configured for this runtime backend",
));
};
match materializer.bind_working_directory(
&working_directory.working_directory_id,
working_directory.relative_cwd.as_deref(),
) {
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
error.to_string(),
),
);
}
}
}
(None, None) => None,
};
let factory = self.factory.clone();
let bridge_context = request.context.clone();
let worker_ref = request.worker_ref.clone();
@ -365,6 +524,12 @@ where
let handle = match spawn_result {
Ok(handle) => handle,
Err(message) => {
if let (Some(materializer), Some(binding)) = (
self.working_directory_materializer.as_ref(),
working_directory.as_ref(),
) {
let _ = materializer.cleanup(binding);
}
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
message,
@ -405,11 +570,19 @@ where
));
}
};
workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy });
workers.insert(
worker_ref.clone(),
RuntimeWorkerExecution {
handle,
busy,
working_directory: working_directory.clone(),
},
);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: working_directory.map(|binding| binding.status()),
}
}
@ -468,19 +641,44 @@ where
}
fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
let (worker, _busy) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::Stop;
return result;
if handle.backend_id() != self.backend_id() {
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Stop,
format!(
"execution handle belongs to backend {}, not {}",
handle.backend_id(),
self.backend_id()
),
);
}
let execution = match self.workers.lock() {
Ok(mut workers) => workers.remove(handle.worker_ref()),
Err(_) => {
return WorkerExecutionResult::errored(
WorkerExecutionOperation::Stop,
"worker adapter registry lock is poisoned",
);
}
};
self.send_method(
let Some(execution) = execution else {
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Stop,
"execution handle does not reference a live Worker",
);
};
let result = self.send_method(
WorkerExecutionOperation::Stop,
worker,
execution.handle,
Method::Shutdown,
WorkerExecutionRunState::Stopped,
)
);
if let (Some(materializer), Some(binding)) = (
self.working_directory_materializer.as_ref(),
execution.working_directory.as_ref(),
) {
let _ = materializer.cleanup(binding);
}
result
}
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
@ -503,14 +701,22 @@ where
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{BTreeMap, VecDeque};
use std::fs;
use std::pin::Pin;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::Runtime as EmbeddedRuntime;
use crate::catalog::{ConfigBundleRef, CreateWorkerRequest, ProfileSelector};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector,
RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest,
};
use crate::execution::WorkerExecutionContext;
use crate::identity::RuntimeId;
use crate::management::RuntimeOptions;
use crate::observation::{TranscriptQuery, TranscriptRole};
use crate::working_directory::LocalGitWorktreeMaterializer;
use async_trait::async_trait;
use futures::Stream;
use llm_engine::Engine;
@ -559,13 +765,14 @@ mod tests {
cwd: PathBuf,
store_dir: PathBuf,
worker_metadata_dir: PathBuf,
observed_cwds: Arc<Mutex<Vec<PathBuf>>>,
}
#[async_trait]
impl RuntimeWorkerFactory for MockFactory {
async fn spawn_controller(
&self,
_request: WorkerExecutionSpawnRequest,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> {
let manifest = WorkerManifest::from_toml(
r#"
@ -590,12 +797,18 @@ mod tests {
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
);
let scope = Scope::writable(&self.cwd).map_err(|err| err.to_string())?;
let cwd = request
.working_directory
.as_ref()
.map(|binding| binding.cwd().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
self.observed_cwds.lock().unwrap().push(cwd.clone());
let scope = Scope::writable(&cwd).map_err(|err| err.to_string())?;
let worker = Worker::new(
manifest,
Engine::new(self.client.clone()),
store,
self.cwd.clone(),
cwd,
scope,
)
.await
@ -637,10 +850,131 @@ mod tests {
label: Some("adapter-test".to_string()),
}],
declarations: Vec::new(),
profile_source_archive: None,
profile_source_archive_handle: None,
}
.with_computed_digest()
}
fn sample_profile_archive() -> crate::profile_archive::ProfileSourceArchive {
let entrypoints =
BTreeMap::from([("default".to_string(), "profiles/default.dcdl".to_string())]);
let sources = BTreeMap::from([(
"profiles/default.dcdl".to_string(),
r#"{
slug = "default";
description = "Default";
scope = "workspace_read";
}"#
.to_string(),
)]);
crate::profile_archive::ProfileSourceArchive::build(
crate::profile_archive::ProfileSourceArchiveInput {
id: "profile-source-archive:test".to_string(),
entrypoints,
imports: BTreeMap::new(),
sources,
},
)
.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 {
let bundle = test_bundle();
CreateWorkerRequest {
@ -650,9 +984,80 @@ mod tests {
digest: bundle.metadata.digest,
},
initial_input: None,
working_directory_request: None,
working_directory: None,
}
}
fn git(path: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {:?} failed", args);
}
fn create_clean_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init"]);
git(
dir.path(),
&["config", "user.email", "test@example.invalid"],
);
git(dir.path(), &["config", "user.name", "Yoi Test"]);
fs::write(dir.path().join("README.md"), "clean\n").unwrap();
git(dir.path(), &["add", "README.md"]);
git(dir.path(), &["commit", "-m", "init"]);
dir
}
fn working_directory_request(repo: &std::path::Path) -> WorkingDirectoryRequest {
WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repo-main".to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
local_path: Some(repo.to_path_buf()),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
}
}
#[tokio::test]
async fn cached_profile_archive_still_requires_backend_authorization() {
let archive = sample_profile_archive();
let handle = handle_for_archive(&archive);
let call_count = Arc::new(AtomicUsize::new(0));
let client = SequencedResourceClient {
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
.resolve_profile_source_archive(&bundle, &spawn_request_with_bundle(bundle.clone()))
.await
.expect("first fetch should authorize and cache archive");
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]
fn builtin_profile_selector_is_not_double_prefixed() {
assert_eq!(
@ -677,12 +1082,14 @@ mod tests {
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
let factory = MockFactory {
client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds,
};
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
let runtime = EmbeddedRuntime::with_execution_backend(
@ -730,4 +1137,47 @@ mod tests {
.any(|event| matches!(event.payload, protocol::Event::TextDone { .. }))
);
}
#[test]
fn worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo() {
let client = MockClient::new(simple_text_events());
let runtime_base = tempfile::tempdir().unwrap();
let repo = create_clean_repo();
let store = tempfile::tempdir().unwrap();
let observed_cwds = Arc::new(Mutex::new(Vec::new()));
let factory = MockFactory {
client,
runtime_base: runtime_base.path().to_path_buf(),
cwd: repo.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: observed_cwds.clone(),
};
let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
runtime_base.path(),
));
let runtime = EmbeddedRuntime::with_execution_backend(
RuntimeOptions {
runtime_id: RuntimeId::new("embedded-materialized"),
..RuntimeOptions::default()
},
Arc::new(backend),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");
request.working_directory_request = Some(working_directory_request(repo.path()));
let detail = runtime.create_worker(request).unwrap();
assert!(detail.execution.working_directory.is_some());
let cwds = observed_cwds.lock().unwrap();
assert_eq!(cwds.len(), 1);
let cwd = &cwds[0];
assert!(cwd.starts_with(runtime_base.path()));
assert!(!cwd.starts_with(repo.path()));
assert!(cwd.join("README.md").exists());
}
}

View File

@ -0,0 +1,925 @@
use crate::catalog::{
DirtyStatePolicy, MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
use crate::identity::WorkerRef;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const WORKING_DIRECTORIES_DIR: &str = "working-directories";
const MATERIALIZATION_RECORD: &str = "materialization.json";
static NEXT_WORKING_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryEvidence {
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_selector: Option<String>,
pub resolved_commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_tree: Option<String>,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectory {
pub id: String,
pub repository_id: String,
pub materializer_kind: MaterializerKind,
pub dirty_state_policy: DirtyStatePolicy,
pub evidence: WorkingDirectoryEvidence,
pub cleanup_target: WorkingDirectoryCleanupTarget,
pub cleanup_policy: String,
pub status: WorkingDirectoryStatusKind,
}
impl WorkingDirectory {
pub fn status_summary(&self) -> WorkingDirectorySummary {
WorkingDirectorySummary {
working_directory_id: self.id.clone(),
repository_id: self.repository_id.clone(),
requested_selector: self.evidence.requested_selector.clone(),
materializer_kind: self.materializer_kind.clone(),
dirty_state_policy: self.dirty_state_policy.clone(),
resolved_commit: Some(self.evidence.resolved_commit.clone()),
resolved_tree: self.evidence.resolved_tree.clone(),
cleanup_target: Some(self.cleanup_target.clone()),
cleanup_policy: Some(self.cleanup_policy.clone()),
status: self.status.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkingDirectoryBinding {
pub working_directory: WorkingDirectory,
pub root: PathBuf,
pub cwd: PathBuf,
working_directory_root: PathBuf,
source_repository_path: PathBuf,
}
impl WorkingDirectoryBinding {
pub fn root(&self) -> &Path {
&self.root
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn working_directory_root(&self) -> &Path {
&self.working_directory_root
}
pub fn source_repository_path(&self) -> &Path {
&self.source_repository_path
}
pub fn status(&self) -> WorkingDirectoryStatus {
WorkingDirectoryStatus {
summary: self.working_directory.status_summary(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkingDirectoryDiagnostic {
pub code: String,
pub message: String,
}
impl WorkingDirectoryDiagnostic {
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
impl std::fmt::Display for WorkingDirectoryDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for WorkingDirectoryDiagnostic {}
pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
fn materialize(
&self,
worker_ref: &WorkerRef,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
fn create(
&self,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
fn bind_working_directory(
&self,
working_directory_id: &str,
relative_cwd: Option<&str>,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
fn list_working_directories(
&self,
) -> Result<Vec<WorkingDirectoryStatus>, WorkingDirectoryDiagnostic>;
fn working_directory_status(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic>;
fn cleanup_working_directory(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic>;
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic>;
}
#[derive(Clone, Debug)]
pub struct LocalGitWorktreeMaterializer {
runtime_root: PathBuf,
}
impl LocalGitWorktreeMaterializer {
pub fn new(runtime_root: impl Into<PathBuf>) -> Self {
Self {
runtime_root: runtime_root.into(),
}
}
pub fn runtime_root(&self) -> &Path {
&self.runtime_root
}
fn working_directory_id(worker_ref: &WorkerRef, repository_id: &str) -> String {
format!(
"{}-{}-{}",
sanitize_path_component(worker_ref.runtime_id.as_str()),
sanitize_path_component(worker_ref.worker_id.as_str()),
sanitize_path_component(repository_id)
)
}
fn working_directory_root(&self, working_directory_id: &str) -> PathBuf {
self.runtime_root
.join(WORKING_DIRECTORIES_DIR)
.join(working_directory_id)
}
fn write_record(
&self,
binding: &WorkingDirectoryBinding,
) -> Result<(), WorkingDirectoryDiagnostic> {
let record = WorkingDirectoryMaterializationRecord {
working_directory: binding.working_directory.clone(),
root: binding.root.clone(),
source_repository_path: binding.source_repository_path.clone(),
};
let path = binding.working_directory_root.join(MATERIALIZATION_RECORD);
let raw = serde_json::to_vec_pretty(&record).map_err(|error| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_serialize_failed",
format!("failed to serialize working directory record: {error}"),
)
})?;
fs::write(&path, raw).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_write_failed",
"failed to write working directory record; backend-private path details were omitted",
)
})
}
fn read_binding(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let working_directory_root = self.working_directory_root(working_directory_id);
let path = working_directory_root.join(MATERIALIZATION_RECORD);
let raw = fs::read(&path).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_not_found",
"working directory working_directory was not found",
)
})?;
let record: WorkingDirectoryMaterializationRecord = serde_json::from_slice(&raw).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_record_invalid",
"working directory working_directory record is invalid; backend-private path details were omitted",
)
})?;
Ok(WorkingDirectoryBinding {
working_directory: record.working_directory,
root: record.root.clone(),
cwd: record.root,
working_directory_root,
source_repository_path: record.source_repository_path,
})
}
fn materialize_with_working_directory_id(
&self,
working_directory_id: String,
request: &WorkingDirectoryRequest,
cleanup_policy: &str,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
validate_working_directory_id(&working_directory_id)?;
if request.materializer != MaterializerKind::LocalGitWorktree {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_materializer_unsupported",
"only local_git_worktree working directory materialization is supported in v0",
));
}
if request.repository.provider != "git" {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_repository_provider_unsupported",
format!(
"repository provider `{}` is not supported by the v0 working directory materializer",
request.repository.provider
),
));
}
if request.dirty_state_policy != DirtyStatePolicy::CleanPointOnly {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_dirty_policy_unsupported",
"only clean_point_only dirty-state policy is supported in v0",
));
}
if is_remote_uri(&request.repository.uri) {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_remote_repository_unsupported",
"remote repository URI materialization is not implemented in v0",
));
}
let source_path = request
.repository
.local_path
.clone()
.unwrap_or_else(|| PathBuf::from(&request.repository.uri));
let source_root = git_stdout(&source_path, ["rev-parse", "--show-toplevel"])
.map(|value| PathBuf::from(value.trim()))
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_git_repository_unavailable",
"configured local repository is not an available Git worktree; backend-private path details were omitted",
)
})?;
let status = git_stdout(&source_root, ["status", "--porcelain"])?;
if !status.trim().is_empty() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_dirty_source_rejected",
"clean_point_only working directory materialization rejects dirty source repository state",
));
}
let selector = request
.repository
.selector
.as_deref()
.unwrap_or("HEAD")
.to_string();
let commit_spec = format!("{selector}^{{commit}}");
let resolved_commit = git_stdout(&source_root, ["rev-parse", commit_spec.as_str()])?
.trim()
.to_string();
let tree_spec = format!("{resolved_commit}^{{tree}}");
let resolved_tree = git_stdout(&source_root, ["rev-parse", tree_spec.as_str()])
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let working_directory_root = self.working_directory_root(&working_directory_id);
let worktree_root = working_directory_root
.join("root")
.join(sanitize_path_component(&request.repository.id));
if worktree_root.exists() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_exists",
"working directory working_directory target already exists; cleanup or choose a new working_directory",
));
}
fs::create_dir_all(worktree_root.parent().ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"working_directory_invalid_target",
"working directory working_directory target has no parent directory",
)
})?)
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_create_failed",
"failed to create working directory working_directory directory; backend-private path details were omitted",
)
})?;
let workspace_worktree_root_arg = path_str(&worktree_root)?;
git_status(
&source_root,
[
"worktree",
"add",
"--detach",
workspace_worktree_root_arg.as_str(),
resolved_commit.as_str(),
],
)?;
let working_directory = WorkingDirectory {
id: working_directory_id.clone(),
repository_id: request.repository.id.clone(),
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
evidence: WorkingDirectoryEvidence {
repository_id: request.repository.id.clone(),
requested_selector: request
.repository
.selector
.as_ref()
.map(|selector| selector.as_ref().to_string()),
resolved_commit,
resolved_tree,
materializer_kind: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
},
cleanup_target: WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(),
working_directory_id,
repository_id: request.repository.id.clone(),
},
cleanup_policy: cleanup_policy.to_string(),
status: WorkingDirectoryStatusKind::Active,
};
let binding = WorkingDirectoryBinding {
working_directory,
root: worktree_root.clone(),
cwd: worktree_root,
working_directory_root,
source_repository_path: source_root,
};
self.write_record(&binding)?;
Ok(binding)
}
}
impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
fn materialize(
&self,
worker_ref: &WorkerRef,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let working_directory_id = Self::working_directory_id(worker_ref, &request.repository.id);
self.materialize_with_working_directory_id(
working_directory_id,
request,
"remove_on_worker_stop",
)
}
fn create(
&self,
request: &WorkingDirectoryRequest,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
let working_directory_id = next_working_directory_id(&request.repository.id);
self.materialize_with_working_directory_id(
working_directory_id,
request,
"manual_or_worker_stop",
)
}
fn bind_working_directory(
&self,
working_directory_id: &str,
relative_cwd: Option<&str>,
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
validate_working_directory_id(working_directory_id)?;
let binding = self.read_binding(working_directory_id)?;
if binding.working_directory.status != WorkingDirectoryStatusKind::Active {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_not_active",
"working directory working_directory is not active",
));
}
let cwd = validate_relative_cwd(binding.root(), relative_cwd)?;
Ok(WorkingDirectoryBinding { cwd, ..binding })
}
fn list_working_directories(
&self,
) -> Result<Vec<WorkingDirectoryStatus>, WorkingDirectoryDiagnostic> {
let root = self.runtime_root.join(WORKING_DIRECTORIES_DIR);
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(_) => {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_list_failed",
"failed to list working directory working_directories; backend-private path details were omitted",
));
}
};
let mut statuses = Vec::new();
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let working_directory_id = entry.file_name().to_string_lossy().to_string();
if validate_working_directory_id(&working_directory_id).is_err() {
continue;
}
if let Ok(status) = self.working_directory_status(&working_directory_id) {
statuses.push(status);
}
}
statuses.sort_by(|left, right| {
left.summary
.working_directory_id
.cmp(&right.summary.working_directory_id)
});
Ok(statuses)
}
fn working_directory_status(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
validate_working_directory_id(working_directory_id)?;
Ok(self.read_binding(working_directory_id)?.status())
}
fn cleanup_working_directory(
&self,
working_directory_id: &str,
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
validate_working_directory_id(working_directory_id)?;
let binding = self.read_binding(working_directory_id)?;
self.cleanup(&binding)?;
self.working_directory_status(working_directory_id)
}
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
let mut working_directory = binding.working_directory.clone();
let working_directory_root = binding.working_directory_root.canonicalize().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_target_invalid",
"working directory working directory root is unavailable; backend-private path details were omitted",
)
})?;
let root = binding.root.canonicalize().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_target_invalid",
"working directory root is unavailable; backend-private path details were omitted",
)
})?;
if !root.starts_with(&working_directory_root) {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_escape_rejected",
"working directory cleanup target is outside the working directory root",
));
}
let workspace_worktree_root_arg = path_str(&root)?;
let remove_result = git_status(
binding.source_repository_path(),
["worktree", "remove", "--force", workspace_worktree_root_arg.as_str()],
)
.or_else(|_| {
if root.exists() {
fs::remove_dir_all(&root).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_cleanup_failed",
"failed to remove working directory; backend-private path details were omitted",
)
})
} else {
Ok(())
}
});
working_directory.status = if remove_result.is_ok() {
WorkingDirectoryStatusKind::Removed
} else {
WorkingDirectoryStatusKind::CleanupPending
};
let updated = WorkingDirectoryBinding {
working_directory,
root: binding.root.clone(),
cwd: binding.cwd.clone(),
working_directory_root: binding.working_directory_root.clone(),
source_repository_path: binding.source_repository_path.clone(),
};
let _ = self.write_record(&updated);
remove_result
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct WorkingDirectoryMaterializationRecord {
working_directory: WorkingDirectory,
root: PathBuf,
source_repository_path: PathBuf,
}
fn git_stdout<'a, I>(repository_path: &Path, args: I) -> Result<String, WorkingDirectoryDiagnostic>
where
I: IntoIterator<Item = &'a str>,
{
let output = Command::new("git")
.arg("-C")
.arg(repository_path)
.args(args)
.output()
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_git_unavailable",
"Git command could not be executed; backend-private path details were omitted",
)
})?;
if !output.status.success() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_git_failed",
"Git command failed; backend-private path details were omitted",
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_status<'a, I>(repository_path: &Path, args: I) -> Result<(), WorkingDirectoryDiagnostic>
where
I: IntoIterator<Item = &'a str>,
{
git_stdout(repository_path, args).map(|_| ())
}
fn path_str(path: &Path) -> Result<String, WorkingDirectoryDiagnostic> {
path.to_str().map(ToString::to_string).ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"working_directory_non_utf8_path",
"working directory path is not valid UTF-8; backend-private path details were omitted",
)
})
}
fn is_remote_uri(uri: &str) -> bool {
uri.contains("://") || uri.starts_with("git@") || uri.starts_with("ssh:")
}
fn sanitize_path_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'-'
}
})
.collect::<String>();
let trimmed = sanitized.trim_matches(['-', '.']).to_string();
if trimmed.is_empty() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
format!("workspace-{now}")
} else {
trimmed
}
}
fn next_working_directory_id(repository_id: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!(
"alloc-{now}-{sequence}-{}",
sanitize_path_component(repository_id)
)
}
fn validate_working_directory_id(
working_directory_id: &str,
) -> Result<(), WorkingDirectoryDiagnostic> {
let sanitized = sanitize_path_component(working_directory_id);
if working_directory_id.is_empty()
|| working_directory_id != sanitized
|| working_directory_id.contains(std::path::MAIN_SEPARATOR)
|| working_directory_id.contains('/')
|| working_directory_id.contains('\\')
{
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_id_invalid",
"working directory working_directory id is invalid",
));
}
Ok(())
}
fn validate_relative_cwd(
root: &Path,
relative_cwd: Option<&str>,
) -> Result<PathBuf, WorkingDirectoryDiagnostic> {
let root = root.canonicalize().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_root_unavailable",
"working directory root is unavailable; backend-private path details were omitted",
)
})?;
let relative = relative_cwd.unwrap_or(".").trim();
if relative.is_empty() || relative == "." {
return Ok(root);
}
let relative_path = Path::new(relative);
if relative_path.is_absolute()
|| relative_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_relative_cwd_invalid",
"working directory relative_cwd must be a relative path inside the working directory root",
));
}
let target = root.join(relative_path).canonicalize().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_relative_cwd_unavailable",
"working directory relative_cwd does not identify an existing directory",
)
})?;
if !target.starts_with(&root) || !target.is_dir() {
return Err(WorkingDirectoryDiagnostic::new(
"working_directory_relative_cwd_escape_rejected",
"working directory relative_cwd must resolve to a directory inside the working directory root",
));
}
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{RepositorySelector, WorkingDirectoryRepository};
use crate::identity::{RuntimeId, WorkerId};
fn git(path: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {:?} failed", args);
}
fn create_clean_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init"]);
git(
dir.path(),
&["config", "user.email", "test@example.invalid"],
);
git(dir.path(), &["config", "user.name", "Yoi Test"]);
fs::write(dir.path().join("README.md"), "clean\n").unwrap();
git(dir.path(), &["add", "README.md"]);
git(dir.path(), &["commit", "-m", "init"]);
dir
}
fn request(repo: &Path) -> WorkingDirectoryRequest {
WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repo-main".to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
local_path: Some(repo.to_path_buf()),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
}
}
fn worker_ref(sequence: u64) -> WorkerRef {
WorkerRef::new(
RuntimeId::new("runtime-test").unwrap(),
WorkerId::generated(sequence),
)
}
#[test]
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let binding = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
assert!(binding.root.starts_with(runtime_root.path()));
assert!(binding.root.join("README.md").exists());
let branch = git_stdout(binding.root(), ["branch", "--show-current"]).unwrap();
assert!(
branch.is_empty(),
"worktree should be detached, got {branch}"
);
assert_eq!(
binding.working_directory.materializer_kind,
MaterializerKind::LocalGitWorktree
);
assert_eq!(
binding.working_directory.dirty_state_policy,
DirtyStatePolicy::CleanPointOnly
);
assert!(
binding
.working_directory_root()
.join(MATERIALIZATION_RECORD)
.exists()
);
}
#[test]
fn multiple_workers_materialize_distinct_paths_for_same_source_repo() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let first = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
let second = materializer
.materialize(&worker_ref(2), &request(repo.path()))
.unwrap();
assert_ne!(first.root, second.root);
assert!(first.root.starts_with(runtime_root.path()));
assert!(second.root.starts_with(runtime_root.path()));
assert!(!first.root.starts_with(repo.path()));
assert!(!second.root.starts_with(repo.path()));
}
#[test]
fn dirty_source_is_rejected_by_clean_point_only_policy() {
let repo = create_clean_repo();
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let error = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap_err();
assert_eq!(error.code, "working_directory_dirty_source_rejected");
assert!(error.message.contains("clean_point_only"));
}
#[test]
fn unsupported_remote_and_non_git_provider_return_typed_diagnostics() {
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let mut remote = request(Path::new("."));
remote.repository.local_path = None;
remote.repository.uri = "https://example.invalid/repo.git".to_string();
let error = materializer
.materialize(&worker_ref(1), &remote)
.unwrap_err();
assert_eq!(
error.code,
"working_directory_remote_repository_unsupported"
);
let mut non_git = remote;
non_git.repository.provider = "archive".to_string();
non_git.repository.uri = ".".to_string();
let error = materializer
.materialize(&worker_ref(2), &non_git)
.unwrap_err();
assert_eq!(
error.code,
"working_directory_repository_provider_unsupported"
);
}
#[test]
fn working_directory_binds_safe_relative_cwd_and_lists_without_paths() {
let repo = create_clean_repo();
fs::create_dir_all(repo.path().join("crates/yoi")).unwrap();
fs::write(repo.path().join("crates/yoi/lib.rs"), "// ok\n").unwrap();
git(repo.path(), &["add", "crates/yoi/lib.rs"]);
git(repo.path(), &["commit", "-m", "add crate"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let working_directory = materializer.create(&request(repo.path())).unwrap();
let bound = materializer
.bind_working_directory(&working_directory.working_directory.id, Some("crates/yoi"))
.unwrap();
assert_eq!(bound.cwd.file_name().unwrap(), "yoi");
let listed = materializer.list_working_directories().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].summary.working_directory_id,
working_directory.working_directory.id
);
assert_eq!(
listed[0].summary.requested_selector.as_deref(),
Some("HEAD")
);
}
#[test]
fn relative_cwd_rejects_absolute_parent_nonexistent_file_and_symlink_escape() {
let repo = create_clean_repo();
fs::create_dir_all(repo.path().join("inside")).unwrap();
fs::write(repo.path().join("inside/file.txt"), "file\n").unwrap();
git(repo.path(), &["add", "inside/file.txt"]);
git(repo.path(), &["commit", "-m", "add inside"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let working_directory = materializer.create(&request(repo.path())).unwrap();
assert_eq!(
materializer
.bind_working_directory(&working_directory.working_directory.id, Some("/tmp"))
.unwrap_err()
.code,
"working_directory_relative_cwd_invalid"
);
assert_eq!(
materializer
.bind_working_directory(&working_directory.working_directory.id, Some("../outside"))
.unwrap_err()
.code,
"working_directory_relative_cwd_invalid"
);
assert_eq!(
materializer
.bind_working_directory(&working_directory.working_directory.id, Some("missing"))
.unwrap_err()
.code,
"working_directory_relative_cwd_unavailable"
);
assert_eq!(
materializer
.bind_working_directory(
&working_directory.working_directory.id,
Some("inside/file.txt")
)
.unwrap_err()
.code,
"working_directory_relative_cwd_escape_rejected"
);
#[cfg(unix)]
{
std::os::unix::fs::symlink("/tmp", working_directory.root().join("escape")).unwrap();
assert_eq!(
materializer
.bind_working_directory(&working_directory.working_directory.id, Some("escape"))
.unwrap_err()
.code,
"working_directory_relative_cwd_escape_rejected"
);
}
}
#[test]
fn cleanup_removes_worktree_and_updates_record() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
let binding = materializer
.materialize(&worker_ref(1), &request(repo.path()))
.unwrap();
let root = binding.root.clone();
materializer.cleanup(&binding).unwrap();
assert!(!root.exists());
let raw = fs::read_to_string(
binding
.working_directory_root()
.join(MATERIALIZATION_RECORD),
)
.unwrap();
assert!(raw.contains("removed"));
}
}

View File

@ -239,6 +239,19 @@ pub fn resolve_runtime_profile_manifest(
Ok((manifest, loader))
}
pub fn resolve_runtime_profile_manifest_from_manifest(
mut manifest: WorkerManifest,
workspace_root: &Path,
worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
if manifest.worker.name.is_empty() {
manifest.worker.name = worker_name.to_string();
}
apply_profile_launch_policy(&mut manifest, workspace_root, None)?;
apply_plugin_resolution_plan(&mut manifest, workspace_root);
Ok((manifest, PromptLoader::builtins_only()))
}
fn load_single_manifest(
path: &Path,
explicit_worker_name: Option<&str>,

View File

@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use chrono::Utc;
@ -6,6 +7,7 @@ use worker_runtime::catalog::ProfileSelector;
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use crate::hosts::{
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
@ -387,6 +389,10 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
},
profile: Some(selector),
initial_input: None,
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
);
@ -449,10 +455,33 @@ fn companion_config_bundle() -> ConfigBundle {
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;
@ -575,6 +604,10 @@ mod tests {
"deterministic-companion-test",
),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
}

View File

@ -1,4 +1,5 @@
use crate::Error;
use crate::resource_broker::BackendResourceBroker;
use chrono::Utc;
use reqwest::StatusCode;
use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder};
@ -7,13 +8,15 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{Arc, RwLock},
time::Duration,
};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest,
WorkingDirectorySummary,
};
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleMetadata, ConfigBundleProvenance,
@ -40,6 +43,7 @@ use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, Runti
use worker_runtime::observation::{
TranscriptProjection as EmbeddedTranscriptProjection, TranscriptQuery, TranscriptRole,
};
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
@ -238,6 +242,8 @@ pub struct WorkerSummary {
pub last_seen_at: Option<String>,
pub implementation: WorkerImplementationSummary,
pub capabilities: WorkerCapabilitySummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectorySummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@ -263,13 +269,26 @@ pub struct WorkerLookupResult {
/// Browser-safe worker spawn request shape.
///
/// The request carries Browser-facing launch semantics only: workspace intent,
/// optional display identity, acceptance policy, optional profile selector, and
/// optional initial input. Runtime execution authority is resolved by the host
/// optional display identity, acceptance policy, optional profile selector,
/// optional initial input, and optional configured Repository selector for
/// working-directory materialization. Runtime execution authority is resolved by the host
/// into a synced ConfigBundle before the canonical Runtime create request is
/// built. Raw workspace roots, child cwd, executable paths, tool scope,
/// credentials, raw config stores, sockets, sessions, and storage paths are not
/// accepted from Workspace API callers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkerSpawnWorkingDirectoryRequest {
/// Safe configured Repository id. The host resolves this id to repository
/// authority from server-side config; browser callers cannot provide raw
/// source paths or runtime-internal storage paths.
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkerSpawnRequest {
pub intent: WorkerSpawnIntent,
#[serde(skip_serializing_if = "Option::is_none")]
@ -279,6 +298,17 @@ pub struct WorkerSpawnRequest {
pub profile: Option<ProfileSelector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<EmbeddedWorkerInput>,
/// Optional safe working-directory creation request. The Workspace server resolves
/// this into a runtime-internal `WorkingDirectoryRequest` from configured
/// repositories before calling a host.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_request: Option<WorkerSpawnWorkingDirectoryRequest>,
#[serde(skip, default)]
pub resolved_working_directory_request: Option<WorkingDirectoryRequest>,
#[serde(skip, default)]
pub resolved_working_directory: Option<WorkingDirectoryClaim>,
#[serde(skip, default)]
pub resolved_config_bundle: Option<ConfigBundle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -1005,6 +1035,7 @@ pub struct EmbeddedWorkerRuntime {
host_id: String,
runtime: worker_runtime::Runtime,
execution_enabled: bool,
resource_broker: BackendResourceBroker,
}
fn embedded_runtime_options() -> EmbeddedRuntimeOptions {
@ -1055,6 +1086,11 @@ impl EmbeddedWorkerRuntime {
Ok(embedded)
}
pub fn with_resource_broker(mut self, resource_broker: BackendResourceBroker) -> Self {
self.resource_broker = resource_broker;
self
}
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
let runtime_id = runtime
.runtime_id()
@ -1066,6 +1102,7 @@ impl EmbeddedWorkerRuntime {
host_id: host_id_for_embedded_workspace(workspace_id.as_ref()),
runtime,
execution_enabled: false,
resource_broker: BackendResourceBroker::default(),
}
}
@ -1118,6 +1155,11 @@ impl EmbeddedWorkerRuntime {
can_stop: self.can_stop_embedded_worker(summary.status, &summary.execution),
can_spawn_followup: false,
},
working_directory: summary
.execution
.working_directory
.clone()
.map(|status| status.summary),
diagnostics: embedded_worker_projection_diagnostics(&summary.execution),
}
}
@ -1147,6 +1189,11 @@ impl EmbeddedWorkerRuntime {
can_stop: self.can_stop_embedded_worker(detail.status, &detail.execution),
can_spawn_followup: false,
},
working_directory: detail
.execution
.working_directory
.clone()
.map(|status| status.summary),
diagnostics: embedded_worker_projection_diagnostics(&detail.execution),
}
}
@ -1302,13 +1349,31 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.profile
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let config_bundle = match self
.runtime
.store_config_bundle(default_embedded_config_bundle(&profile))
{
let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone());
let config_bundle = match request
.resolved_config_bundle
.clone()
.map(Ok)
.unwrap_or_else(|| {
default_embedded_config_bundle(
&profile,
&self.host_id,
runtime_id.as_ref(),
&self.resource_broker,
)
})
.and_then(|bundle| {
self.runtime
.store_config_bundle(bundle)
.map_err(|err| err.to_string())
}) {
Ok(availability) => availability.reference,
Err(error) => {
diagnostics.push(embedded_runtime_diagnostic(&error));
diagnostics.push(diagnostic(
"embedded_profile_source_archive_invalid",
DiagnosticSeverity::Error,
error,
));
return WorkerSpawnResult {
state: WorkerOperationState::Rejected,
worker: None,
@ -1321,6 +1386,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(),
};
match self.runtime.create_worker(create_request) {
Ok(detail) => {
@ -1673,6 +1740,7 @@ pub struct RemoteWorkerRuntime {
cached_capabilities: RuntimeCapabilitySummary,
cached_status: String,
host_id: String,
resource_broker: BackendResourceBroker,
http: BlockingHttpClient,
}
@ -1696,10 +1764,16 @@ impl RemoteWorkerRuntime {
bearer_token: config.bearer_token,
cached_capabilities: config.cached_capabilities,
cached_status: config.cached_status,
resource_broker: BackendResourceBroker::default(),
http,
})
}
pub fn with_resource_broker(mut self, resource_broker: BackendResourceBroker) -> Self {
self.resource_broker = resource_broker;
self
}
fn endpoint(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
@ -1796,6 +1870,7 @@ impl RemoteWorkerRuntime {
can_stop: runtime_worker_can_stop(true, summary.status, &summary.execution),
can_spawn_followup: false,
},
working_directory: summary.execution.working_directory.clone().map(|status| status.summary),
diagnostics: vec![diagnostic(
"remote_runtime_projection",
DiagnosticSeverity::Info,
@ -1829,6 +1904,7 @@ impl RemoteWorkerRuntime {
can_stop: runtime_worker_can_stop(true, detail.status, &detail.execution),
can_spawn_followup: false,
},
working_directory: detail.execution.working_directory.clone().map(|status| status.summary),
diagnostics: vec![diagnostic(
"remote_runtime_projection",
DiagnosticSeverity::Info,
@ -1979,7 +2055,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.profile
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let sync = self.sync_config_bundle(default_embedded_config_bundle(&profile));
let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone());
let sync = match request
.resolved_config_bundle
.clone()
.map(Ok)
.unwrap_or_else(|| {
default_embedded_config_bundle(
&profile,
&self.host_id,
runtime_id.as_ref(),
&self.resource_broker,
)
}) {
Ok(bundle) => self.sync_config_bundle(bundle),
Err(error) => ConfigBundleSyncResult {
state: WorkerOperationState::Rejected,
availability: None,
diagnostics: vec![diagnostic(
"remote_profile_source_archive_invalid",
DiagnosticSeverity::Error,
error,
)],
},
};
let Some(config_bundle) = sync.availability.map(|availability| availability.reference)
else {
return WorkerSpawnResult {
@ -1993,6 +2092,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
profile,
config_bundle,
initial_input: request.initial_input.clone(),
working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(),
};
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
Ok(response) => WorkerSpawnResult {
@ -2306,19 +2407,31 @@ fn embedded_worker_execution_status_label(
}
}
fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle {
fn default_embedded_config_bundle(
profile: &ProfileSelector,
workspace_id: &str,
runtime_id: Option<&EmbeddedRuntimeId>,
resource_broker: &BackendResourceBroker,
) -> Result<ConfigBundle, String> {
let id = format!(
"workspace-runtime-{}",
embedded_profile_label(profile)
.unwrap_or_else(|| "default".to_string())
.replace([':', '/', ' '], "-")
);
ConfigBundle {
let archive = default_profile_source_archive(profile)?;
let handle = resource_broker.issue_profile_source_archive_handle(
workspace_id.to_string(),
runtime_id,
None,
archive,
);
Ok(ConfigBundle {
metadata: ConfigBundleMetadata {
id,
digest: String::new(),
revision: "workspace-runtime-v0".to_string(),
workspace_id: "workspace-server".to_string(),
workspace_id: workspace_id.to_string(),
created_at: "runtime-generated".to_string(),
provenance: ConfigBundleProvenance {
source: "workspace-server".to_string(),
@ -2330,8 +2443,77 @@ fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle {
label: embedded_profile_label(profile),
}],
declarations: Vec::new(),
profile_source_archive: None,
profile_source_archive_handle: Some(handle),
}
.with_computed_digest())
}
fn default_profile_source_archive(
profile: &ProfileSelector,
) -> Result<ProfileSourceArchive, String> {
let selected = embedded_profile_label(profile).unwrap_or_else(|| "default".to_string());
let selected_path = embedded_profile_path(profile)?;
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string());
entrypoints.insert(
"builtin:default".to_string(),
"profiles/default.dcdl".to_string(),
);
for slug in ["companion", "intake", "orchestrator", "coder", "reviewer"] {
entrypoints.insert(format!("builtin:{slug}"), format!("profiles/{slug}.dcdl"));
}
entrypoints.insert(selected, selected_path);
let mut sources = BTreeMap::new();
sources.insert(
"profiles/default.dcdl".to_string(),
include_str!("../../../resources/profiles/default.dcdl").to_string(),
);
sources.insert(
"profiles/companion.dcdl".to_string(),
include_str!("../../../resources/profiles/companion.dcdl").to_string(),
);
sources.insert(
"profiles/intake.dcdl".to_string(),
include_str!("../../../resources/profiles/intake.dcdl").to_string(),
);
sources.insert(
"profiles/orchestrator.dcdl".to_string(),
include_str!("../../../resources/profiles/orchestrator.dcdl").to_string(),
);
sources.insert(
"profiles/coder.dcdl".to_string(),
include_str!("../../../resources/profiles/coder.dcdl").to_string(),
);
sources.insert(
"profiles/reviewer.dcdl".to_string(),
include_str!("../../../resources/profiles/reviewer.dcdl").to_string(),
);
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "builtin-decodal-profiles-v1".to_string(),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.map_err(|err| err.to_string())
}
fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> {
match profile {
ProfileSelector::RuntimeDefault => Ok("profiles/default.dcdl".to_string()),
ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) {
"default" => Ok("profiles/default.dcdl".to_string()),
"companion" => Ok("profiles/companion.dcdl".to_string()),
"intake" => Ok("profiles/intake.dcdl".to_string()),
"orchestrator" => Ok("profiles/orchestrator.dcdl".to_string()),
"coder" => Ok("profiles/coder.dcdl".to_string()),
"reviewer" => Ok("profiles/reviewer.dcdl".to_string()),
other => Err(format!("unknown builtin profile selector: builtin:{other}")),
},
ProfileSelector::Named(name) => Err(format!("unknown named profile selector: {name}")),
}
.with_computed_digest()
}
fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
@ -2500,10 +2682,12 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
DiagnosticSeverity::Warning,
"Embedded Worker has no execution backend attached".to_string(),
),
EmbeddedRuntimeError::WorkerExecutionRejected { .. } => diagnostic(
EmbeddedRuntimeError::WorkerExecutionRejected {
operation, outcome, ..
} => diagnostic(
"embedded_worker_execution_rejected",
DiagnosticSeverity::Warning,
"Embedded Worker execution backend rejected the operation".to_string(),
format!("Embedded Worker execution backend rejected {operation:?} with {outcome:?}"),
),
EmbeddedRuntimeError::LimitTooLarge { requested, max } => diagnostic(
"embedded_runtime_limit_too_large",
@ -2803,6 +2987,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
can_stop: false,
can_spawn_followup: false,
},
working_directory: None,
diagnostics: vec![diagnostic(
"runtime_capability_unsupported",
DiagnosticSeverity::Info,
@ -2834,6 +3019,79 @@ mod tests {
use std::sync::{Arc, Mutex};
use std::thread;
#[test]
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
let root = tempfile::tempdir().unwrap();
let broker = BackendResourceBroker::default();
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
for selector in [
ProfileSelector::RuntimeDefault,
ProfileSelector::Builtin("builtin:companion".to_string()),
ProfileSelector::Builtin("builtin:intake".to_string()),
ProfileSelector::Builtin("builtin:orchestrator".to_string()),
ProfileSelector::Builtin("builtin:coder".to_string()),
ProfileSelector::Builtin("builtin:reviewer".to_string()),
] {
let bundle = default_embedded_config_bundle(
&selector,
"workspace-test",
Some(&runtime_id),
&broker,
)
.unwrap();
let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
assert!(bundle.profile_source_archive.is_none());
let response = broker
.fetch_profile_source_archive(
worker_runtime::resource::BackendResourceFetchRequest {
handle: handle.clone(),
runtime_id: runtime_id.as_str().to_string(),
worker_id: None,
audit_correlation_id: handle.audit_correlation_id.clone(),
},
)
.unwrap();
let archive =
worker_runtime::resource::profile_source_archive_from_response(handle, response)
.unwrap()
.verify()
.unwrap();
let selector_key = match &selector {
ProfileSelector::RuntimeDefault => "default".to_string(),
ProfileSelector::Builtin(name) => name.clone(),
ProfileSelector::Named(name) => name.clone(),
};
let manifest = archive
.resolve_profile(&selector_key, root.path(), "embedded-test-worker")
.unwrap();
assert_eq!(manifest.worker.name, "embedded-test-worker");
}
}
#[test]
fn embedded_archive_rejects_unknown_selectors() {
let broker = BackendResourceBroker::default();
let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap();
assert!(
default_embedded_config_bundle(
&ProfileSelector::Builtin("builtin:missing".to_string()),
"workspace-test",
Some(&runtime_id),
&broker,
)
.is_err()
);
assert!(
default_embedded_config_bundle(
&ProfileSelector::Named("custom".to_string()),
"workspace-test",
Some(&runtime_id),
&broker,
)
.is_err()
);
}
fn test_config_bundle() -> ConfigBundle {
ConfigBundle {
metadata: worker_runtime::config_bundle::ConfigBundleMetadata {
@ -2856,6 +3114,8 @@ mod tests {
name: "read".to_string(),
reference: "capability:read".to_string(),
}],
profile_source_archive: None,
profile_source_archive_handle: None,
}
.with_computed_digest()
}
@ -2916,6 +3176,10 @@ mod tests {
self.backend_id(),
),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
}
}
@ -2994,6 +3258,7 @@ mod tests {
can_stop: false,
can_spawn_followup: false,
},
working_directory: None,
diagnostics: Vec::new(),
}],
}
@ -3144,6 +3409,10 @@ mod tests {
},
profile: None,
initial_input: None,
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
}
}
@ -3273,6 +3542,10 @@ mod tests {
},
profile: None,
initial_input: None,
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.unwrap();
@ -3372,6 +3645,10 @@ mod tests {
},
profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())),
initial_input: None,
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.unwrap();
@ -3400,6 +3677,10 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: None,
initial_input: None,
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
resolved_config_bundle: None,
},
)
.unwrap();

View File

@ -9,8 +9,10 @@ pub mod config;
pub mod hosts;
pub mod identity;
pub mod observation;
pub mod profile_settings;
pub mod records;
pub mod repositories;
pub mod resource_broker;
pub mod server;
pub mod store;
@ -71,6 +73,8 @@ pub enum Error {
},
#[error("unknown local repository `{0}`")]
UnknownRepository(String),
#[error("workspace id does not match this Workspace backend")]
WorkspaceIdMismatch,
#[error("workspace identity error: {0}")]
WorkspaceIdentity(String),
#[error("store error: {0}")]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,336 @@
use async_trait::async_trait;
use chrono::{Duration, Utc};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
use worker_runtime::identity::{RuntimeId, WorkerId};
use worker_runtime::profile_archive::ProfileSourceArchive;
use worker_runtime::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest,
BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind,
BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE, ResourceRedactionPolicy,
};
#[derive(Clone, Default)]
pub struct BackendResourceBroker {
resources: Arc<Mutex<HashMap<String, StoredResource>>>,
}
#[derive(Clone)]
struct StoredResource {
runtime_id: Option<String>,
worker_id: Option<String>,
handle: BackendResourceHandle,
archive: ProfileSourceArchive,
}
impl BackendResourceBroker {
pub fn issue_profile_source_archive_handle(
&self,
workspace_id: impl Into<String>,
runtime_id: Option<&RuntimeId>,
worker_id: Option<&WorkerId>,
archive: ProfileSourceArchive,
) -> BackendResourceHandle {
let workspace_id = workspace_id.into();
let nonce = Uuid::now_v7().to_string();
let audit_correlation_id = format!("resource-fetch-{nonce}");
let expires_at = Utc::now() + Duration::minutes(15);
let handle = BackendResourceHandle {
kind: BackendResourceKind::ProfileSourceArchive,
workspace_id: workspace_id.clone(),
scope_id: Some("workspace-profile-source".to_string()),
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
worker_id: worker_id.map(|id| id.as_str().to_string()),
resource_id: archive.reference.id.clone(),
digest: archive.reference.digest.clone(),
operation: BackendResourceOperation::FetchArchive,
expires_at_unix_seconds: expires_at.timestamp(),
nonce: nonce.clone(),
revision: archive.reference.digest.clone(),
generation: None,
max_bytes: DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id,
profile_source_graph: Some(archive.reference.source_graph.clone()),
};
let stored = StoredResource {
runtime_id: runtime_id.map(|id| id.as_str().to_string()),
worker_id: worker_id.map(|id| id.as_str().to_string()),
handle: handle.clone(),
archive,
};
if let Ok(mut resources) = self.resources.lock() {
resources.insert(nonce, stored);
}
handle
}
pub fn fetch_profile_source_archive(
&self,
request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
verify_handle_shape(&request.handle)?;
let stored = self
.resources
.lock()
.map_err(|_| BackendResourceError::Transport {
message: "resource broker lock poisoned".to_string(),
})?
.get(&request.handle.nonce)
.cloned()
.ok_or(BackendResourceError::MissingResource)?;
verify_handle_shape(&stored.handle)?;
if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() {
return Err(BackendResourceError::Expired);
}
let actual_bytes = stored.archive.content.len() as u64;
if actual_bytes > stored.handle.max_bytes {
return Err(BackendResourceError::Oversized {
max_bytes: stored.handle.max_bytes,
actual_bytes,
});
}
if request.handle != stored.handle {
return Err(BackendResourceError::Unauthorized {
message: "resource handle does not match broker-issued handle".to_string(),
});
}
if let Some(expected_runtime_id) = stored.runtime_id.as_deref() {
if expected_runtime_id != request.runtime_id {
return Err(BackendResourceError::Unauthorized {
message: "runtime id does not match resource handle".to_string(),
});
}
}
if let Some(expected_worker_id) = stored.worker_id.as_deref() {
if Some(expected_worker_id) != request.worker_id.as_deref() {
return Err(BackendResourceError::Unauthorized {
message: "worker id does not match resource handle".to_string(),
});
}
}
Ok(BackendResourceFetchResponse {
kind: BackendResourceKind::ProfileSourceArchive,
resource_id: stored.archive.reference.id,
digest: stored.archive.reference.digest,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
bytes: stored.archive.content,
audit_correlation_id: request.audit_correlation_id,
})
}
}
#[async_trait]
impl BackendResourceClient for BackendResourceBroker {
async fn fetch_resource(
&self,
request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
self.fetch_profile_source_archive(request)
}
}
fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive {
return Err(BackendResourceError::UnsupportedKind);
}
if handle.operation != BackendResourceOperation::FetchArchive {
return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_archive".to_string(),
});
}
if handle.content_type != PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE {
return Err(BackendResourceError::ContentTypeMismatch {
expected: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
actual: handle.content_type.clone(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use worker_runtime::identity::{RuntimeId, WorkerId};
use worker_runtime::profile_archive::{
ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex,
};
fn archive() -> ProfileSourceArchive {
let content = b"archive-content".to_vec();
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string());
ProfileSourceArchive {
reference: ProfileSourceArchiveRef {
id: "profile-source-archive:test".to_string(),
digest: sha256_hex(&content),
size_bytes: content.len() as u64,
source_graph: ProfileSourceGraphSummary {
entrypoints,
source_count: 1,
import_count: 0,
total_source_bytes: content.len() as u64,
},
},
content,
}
}
fn archive_with_len(len: usize) -> ProfileSourceArchive {
let content = vec![b'x'; len];
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string());
ProfileSourceArchive {
reference: ProfileSourceArchiveRef {
id: format!("profile-source-archive:test-{len}"),
digest: sha256_hex(&content),
size_bytes: content.len() as u64,
source_graph: ProfileSourceGraphSummary {
entrypoints,
source_count: 1,
import_count: 0,
total_source_bytes: content.len() as u64,
},
},
content,
}
}
fn request(
handle: BackendResourceHandle,
runtime_id: &RuntimeId,
worker_id: Option<&WorkerId>,
) -> BackendResourceFetchRequest {
BackendResourceFetchRequest {
audit_correlation_id: handle.audit_correlation_id.clone(),
handle,
runtime_id: runtime_id.as_str().to_string(),
worker_id: worker_id.map(|id| id.as_str().to_string()),
}
}
#[test]
fn broker_issues_and_verifies_profile_source_archive_handles() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
None,
archive(),
);
let response = broker
.fetch_profile_source_archive(BackendResourceFetchRequest {
handle: handle.clone(),
runtime_id: runtime_id.as_str().to_string(),
worker_id: None,
audit_correlation_id: handle.audit_correlation_id.clone(),
})
.expect("fetch succeeds");
assert_eq!(response.digest, handle.digest);
assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE);
}
#[test]
fn broker_rejects_runtime_mismatch() {
let broker = BackendResourceBroker::default();
let runtime_a = RuntimeId::new("runtime-a").unwrap();
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_a),
None,
archive(),
);
let err = broker
.fetch_profile_source_archive(request(
handle,
&RuntimeId::new("runtime-b").unwrap(),
None,
))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
}
#[test]
fn broker_rejects_worker_mismatch() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let worker_a = WorkerId::new("worker-a").unwrap();
let worker_b = WorkerId::new("worker-b").unwrap();
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
Some(&worker_a),
archive(),
);
let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, Some(&worker_b)))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
}
#[test]
fn broker_rejects_expiry_extension_from_request_handle() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
None,
archive(),
);
broker
.resources
.lock()
.unwrap()
.get_mut(&handle.nonce)
.unwrap()
.handle
.expires_at_unix_seconds = 1;
let mut extended = handle;
extended.expires_at_unix_seconds = 4_102_444_800;
let err = broker
.fetch_profile_source_archive(request(extended, &runtime_id, None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Expired));
}
#[test]
fn broker_rejects_policy_tampered_request_handle() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let mut handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
None,
archive(),
);
handle.scope_id = Some("tampered-scope".to_string());
let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
}
#[test]
fn broker_uses_stored_max_bytes_when_request_handle_is_tampered() {
let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap();
let archive = archive_with_len((DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1) as usize);
let mut handle = broker.issue_profile_source_archive_handle(
"workspace-test",
Some(&runtime_id),
None,
archive,
);
handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024;
let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Oversized { .. }));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-9e99NfbErWlmyZqXd7d5UaJ88gx6ENbHOubqYtnjXVg=";
cargoHash = "sha256-Pk6pRrtQlMAw2Shl+nQyX81FHQsHlKYAsxtg0M4Ya8Y=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -0,0 +1,12 @@
slug = "coder";
description = "Ticket implementation coder profile.";
scope = "workspace_write";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = false; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};

View File

@ -0,0 +1,12 @@
slug = "companion";
description = "Workspace companion profile.";
scope = "workspace_write";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = true; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};

View File

@ -0,0 +1,38 @@
slug = "default";
description = "Default Yoi coding profile.";
scope = "workspace_write";
model = {
ref = "codex-oauth/gpt-5.5";
};
session = {
record_event_trace = true;
};
engine = {
reasoning = "high";
};
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = true; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};
memory = {
extract_threshold = 50000;
consolidation_threshold_files = 5;
consolidation_threshold_bytes = 50000;
};
web = {
enabled = true;
search = {
provider = "brave";
api_key_secret = "web/brave/default";
};
};

View File

@ -0,0 +1,12 @@
slug = "intake";
description = "Ticket intake profile.";
scope = "workspace_write";
feature = {
task = { enabled = false; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = false; };
ticket = { enabled = true; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};

View File

@ -0,0 +1,12 @@
slug = "orchestrator";
description = "Ticket orchestrator profile.";
scope = "workspace_write";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = true; };
ticket = { enabled = true; access = "lifecycle"; };
ticket_orchestration = { enabled = true; };
};

View File

@ -0,0 +1,12 @@
slug = "reviewer";
description = "Ticket review profile.";
scope = "workspace_read";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = false; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"test": "deno test --allow-read=src src/lib/workspace-api/http.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -146,6 +146,10 @@
min-width: 0;
}
.workspace-layout.sidebar-collapsed {
grid-template-columns: max-content minmax(0, 1fr);
}
.shell {
display: flex;
flex-direction: column;
@ -181,6 +185,10 @@
overflow: visible;
}
.workspace-layout.sidebar-collapsed {
grid-template-columns: 1fr;
}
.shell {
overflow: visible;
padding: var(--space-5) var(--space-4);
@ -194,32 +202,44 @@
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-6) var(--space-5);
padding: var(--space-4) var(--space-3);
}
.sidebar-header {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-2);
min-width: 0;
}
.sidebar-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-6);
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.workspace-label {
display: grid;
gap: var(--space-1);
flex: 1 1 auto;
min-width: 0;
}
.workspace-sidebar h1 {
margin: 0;
.workspace-name {
display: block;
min-width: 0;
overflow: hidden;
border-radius: var(--radius-soft);
padding: 0.45rem 0.6rem;
color: var(--text-strong);
font-size: 1.05rem;
font-weight: 800;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-status {
margin: 0;
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: 0.78rem;
line-height: 1.35;
@ -231,17 +251,69 @@
color: var(--danger);
}
.settings-button {
.sidebar-actions-row {
display: flex;
justify-content: flex-end;
gap: var(--space-1);
}
.sidebar-icon-button,
.sidebar-collapse-button {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius-soft);
background: var(--bg-subtle);
color: var(--text-muted);
cursor: not-allowed;
text-decoration: none;
}
.sidebar-collapse-button {
border: 0;
background: transparent;
cursor: pointer;
}
.sidebar-icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-icon-button:hover,
.sidebar-icon-button:focus-visible,
.sidebar-collapse-button:hover,
.sidebar-collapse-button:focus-visible {
background: var(--interactive-hover);
color: var(--text-muted);
}
.workspace-sidebar.collapsed {
overflow: hidden;
padding-inline: var(--space-2);
}
.workspace-sidebar.collapsed .sidebar-header {
margin-bottom: 0;
}
.workspace-sidebar.collapsed .sidebar-title-row,
.workspace-sidebar.collapsed .sidebar-actions-row {
justify-content: center;
}
.workspace-sidebar.collapsed .sidebar-actions-row {
align-items: center;
flex-direction: column;
}
.workspace-sidebar.collapsed .workspace-label {
display: none;
}
.sidebar-sections {
@ -1339,3 +1411,38 @@
background: rgba(255, 255, 255, 0.04);
padding: 0.75rem;
}
@layer components {
.worker-submit-error {
display: grid;
gap: var(--space-2);
}
.worker-submit-error p {
margin: 0;
}
.worker-error-diagnostics {
display: grid;
gap: var(--space-1);
margin: 0;
padding-left: var(--space-4);
}
.worker-error-diagnostics li {
color: var(--text-muted);
}
.worker-error-diagnostics li.error {
color: var(--danger);
}
.worker-error-diagnostics strong {
color: inherit;
font-weight: 750;
}
.worker-error-diagnostics span {
display: block;
}
}

View File

@ -0,0 +1,55 @@
import { workspaceApiPath, workspaceRoute } from "./http.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: string | URL): Promise<string>;
};
function assertEquals<T>(actual: T, expected: T): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
}
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
Deno.test("workspace route helpers scope browser routes and API by immutable workspace id", () => {
assertEquals(workspaceRoute("workspace 1"), "/w/workspace%201");
assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives");
assertEquals(
workspaceApiPath("workspace 1", "/repositories/repo-a"),
"/api/w/workspace%201/repositories/repo-a",
);
});
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
const layout = await Deno.readTextFile(new URL("../../routes/+layout.ts", import.meta.url));
assert(
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
"unscoped layout may use only the workspace-id bootstrap endpoint",
);
assert(
layout.includes("throw redirect(307") && layout.includes("workspaceRoute("),
"unscoped layout should redirect to the scoped workspace route",
);
assert(
!layout.includes('`/api${path}`') && !layout.includes('"/api/repositories"'),
"layout must not fall back to unscoped workspace-scoped API calls",
);
const unscopedSettings = await Deno.readTextFile(
new URL("../../routes/settings/+page.svelte", import.meta.url),
);
assert(
unscopedSettings.includes("Redirecting to scoped settings") &&
!unscopedSettings.includes("fetch(") &&
!unscopedSettings.includes("settingsApiPath"),
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
);
});

View File

@ -0,0 +1,72 @@
export type ApiResult<T> = {
data: T | null;
error: string | null;
};
function normalizePath(path: string): string {
if (!path || path === '/') return '';
return path.startsWith('/') ? path : `/${path}`;
}
export function workspaceRoute(workspaceId: string, path = ''): string {
return `/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
}
export function workspaceApiPath(workspaceId: string, path = ''): string {
return `/api/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
}
export async function loadJson<T>(
fetchFn: typeof fetch,
path: string,
): Promise<ApiResult<T>> {
try {
const response = await fetchFn(path);
if (!response.ok) {
const text = await response.text();
return {
data: null,
error: text || `${path} request failed (${response.status})`,
};
}
return { data: (await response.json()) as T, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : `${path} request failed`,
};
}
}
async function requireJson<T>(response: Response, path: string): Promise<T> {
if (!response.ok) {
const text = await response.text();
throw new Error(text || `${path} request failed (${response.status})`);
}
return (await response.json()) as T;
}
export async function workspaceApiJson<T>(path: string): Promise<T> {
return requireJson<T>(await fetch(path), path);
}
export async function workspaceApiJsonWithBody<T>(path: string, init: RequestInit): Promise<T> {
return requireJson<T>(
await fetch(path, {
headers: {
"content-type": "application/json",
...(init.headers ?? {}),
},
...init,
}),
path,
);
}
export function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}

View File

@ -24,8 +24,8 @@ Deno.test("workerConsoleHref encodes runtime and worker target authority", () =>
workerConsoleHref({
runtime_id: "local runtime",
worker_id: "worker/one",
}) ===
"/runtimes/local%20runtime/workers/worker%2Fone/console",
}, "workspace-1") ===
"/w/workspace-1/runtimes/local%20runtime/workers/worker%2Fone/console",
"href should contain encoded runtime_id and worker_id segments",
);
});

View File

@ -4,6 +4,7 @@ import type {
Segment,
} from "$lib/generated/protocol";
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
import { workspaceRoute } from "$lib/workspace-api/http";
export type ConsoleLineKind =
| "user"
@ -40,16 +41,23 @@ export type WorkerTarget = {
worker_id: string;
};
export function workerConsoleHref(target: WorkerTarget): string {
return `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
encodeURIComponent(
target.worker_id,
)
}/console`;
export function workerConsoleHref(target: WorkerTarget, workspaceId: string): string {
return workspaceRoute(
workspaceId,
`/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
encodeURIComponent(
target.worker_id,
)
}/console`,
);
}
export function workerConsolePath(runtimeId: string, workerId: string): string {
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId });
export function workerConsolePath(
workspaceId: string,
runtimeId: string,
workerId: string,
): string {
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId);
}
export function initialConsoleLines(items: WorkerTranscriptItem[]): ConsoleLine[] {

View File

@ -11,7 +11,7 @@ function assert(condition: unknown, message: string): asserts condition {
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
const workspacePage = await Deno.readTextFile(
new URL("../workspace-pages/WorkspacePage.svelte", import.meta.url),
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
);
const workersNav = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
@ -21,12 +21,12 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs
);
assert(
workspacePage.includes("workerConsoleHref(worker)") &&
workspacePage.includes("workerConsoleHref(worker, workspaceId)") &&
workspacePage.includes("Open Console"),
"top Worker list should expose an attach action per Worker",
);
assert(
workersNav.includes("workerConsoleHref(worker)") &&
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
workersNav.includes("aria-current"),
"Workers sidebar rows should link to the Worker target Console route",
);
@ -40,26 +40,28 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const routeLoad = await Deno.readTextFile(
new URL(
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
import.meta.url,
),
);
assert(
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
"route load should expose both target ids",
routeLoad.includes("workspaceId") &&
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
"route load should expose workspace and target ids",
);
assert(
consolePage.includes(
"/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}",
),
"Worker detail should use the backend Worker detail API",
consolePage.includes("workspaceApiPath(workspaceId, path)") &&
consolePage.includes(
"workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)",
),
"Worker detail should use the scoped backend Worker detail API",
);
assert(
consolePage.includes("/transcript?limit=200") &&

View File

@ -1,512 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
import { workerConsoleHref } from '$lib/workspace-console/model';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type {
Host,
ListResponse,
ObjectiveDetail,
ObjectiveListResponse,
RepositoryDetailResponse,
RepositoryListResponse,
RepositorySummary,
RepositoryTicketsResponse,
Worker,
WorkspaceResponse
} from '$lib/workspace-sidebar/types';
type WorkspaceView = 'overview' | 'repository' | 'objectives' | 'objective';
type RouteState =
| { page: 'overview'; objectiveId?: undefined; repositoryId?: undefined }
| { page: 'repository'; repositoryId: string; objectiveId?: undefined }
| { page: 'objectives'; objectiveId?: undefined; repositoryId?: undefined }
| { page: 'objective'; objectiveId: string; repositoryId?: undefined };
let {
view = 'overview',
objectiveId = null,
repositoryId = null
}: { view?: WorkspaceView; repositoryId?: string | null; objectiveId?: string | null } = $props();
let workspace = $state<WorkspaceResponse | null>(null);
let hosts = $state<ListResponse<Host> | null>(null);
let workers = $state<ListResponse<Worker> | null>(null);
let repositories = $state<RepositoryListResponse | null>(null);
let repository = $state<RepositorySummary | null>(null);
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
let objectives = $state<ObjectiveListResponse | null>(null);
let objectiveDetail = $state<ObjectiveDetail | null>(null);
let workspaceError = $state<string | null>(null);
let hostsError = $state<string | null>(null);
let workersError = $state<string | null>(null);
let repositoriesError = $state<string | null>(null);
let repositoryError = $state<string | null>(null);
let repositoryTicketsError = $state<string | null>(null);
let objectivesError = $state<string | null>(null);
let objectiveDetailError = $state<string | null>(null);
let objectiveDetailLoading = $state(false);
let objectiveDetailRequest = 0;
let route = $derived(routeFromView(view, objectiveId, repositoryId));
let currentPath = $derived(pathFromRoute(route));
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(path, { signal });
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function loadWorkspace(signal?: AbortSignal) {
workspaceError = null;
try {
workspace = await getJson<WorkspaceResponse>('/api/workspace', signal);
} catch (error) {
workspaceError = error instanceof Error ? error.message : String(error);
workspace = null;
}
}
async function loadHosts(signal?: AbortSignal) {
hostsError = null;
try {
hosts = await getJson<ListResponse<Host>>('/api/hosts', signal);
} catch (error) {
hostsError = error instanceof Error ? error.message : String(error);
hosts = null;
}
}
async function loadWorkers(signal?: AbortSignal) {
workersError = null;
try {
workers = await getJson<ListResponse<Worker>>('/api/workers', signal);
} catch (error) {
workersError = error instanceof Error ? error.message : String(error);
workers = null;
}
}
async function loadRepositories(signal?: AbortSignal) {
repositoriesError = null;
try {
repositories = await getJson<RepositoryListResponse>('/api/repositories', signal);
} catch (error) {
repositoriesError = error instanceof Error ? error.message : String(error);
repositories = null;
}
}
async function loadRepository(id: string) {
repositoryError = null;
try {
const detail = await getJson<RepositoryDetailResponse>(
`/api/repositories/${encodeURIComponent(id)}`
);
repository = detail.item;
} catch (error) {
repositoryError = error instanceof Error ? error.message : String(error);
repository = null;
}
}
async function loadRepositoryTickets(id: string) {
repositoryTicketsError = null;
try {
repositoryTickets = await getJson<RepositoryTicketsResponse>(
`/api/repositories/${encodeURIComponent(id)}/tickets`
);
} catch (error) {
repositoryTicketsError = error instanceof Error ? error.message : String(error);
repositoryTickets = null;
}
}
async function loadObjectives(signal?: AbortSignal) {
objectivesError = null;
try {
objectives = await getJson<ObjectiveListResponse>('/api/objectives', signal);
} catch (error) {
objectivesError = error instanceof Error ? error.message : String(error);
objectives = null;
}
}
async function loadObjectiveDetail(id: string) {
const request = ++objectiveDetailRequest;
objectiveDetailLoading = true;
objectiveDetailError = null;
objectiveDetail = null;
try {
const detail = await getJson<ObjectiveDetail>(`/api/objectives/${encodeURIComponent(id)}`);
if (request === objectiveDetailRequest) {
objectiveDetail = detail;
}
} catch (error) {
if (request === objectiveDetailRequest) {
objectiveDetailError = error instanceof Error ? error.message : String(error);
}
} finally {
if (request === objectiveDetailRequest) {
objectiveDetailLoading = false;
}
}
}
function routeFromView(
view: WorkspaceView,
objectiveId: string | null,
repositoryId: string | null
): RouteState {
if (view === 'repository' && repositoryId) {
return { page: 'repository', repositoryId };
}
if (view === 'objective' && objectiveId) {
return { page: 'objective', objectiveId };
}
if (view === 'objectives') {
return { page: 'objectives' };
}
return { page: 'overview' };
}
function pathFromRoute(route: RouteState): string {
if (route.page === 'repository') {
return `/repositories/${route.repositoryId}`;
}
if (route.page === 'objective') {
return `/objectives/${route.objectiveId}`;
}
if (route.page === 'objectives') {
return '/objectives';
}
return '/';
}
function formatDate(value: string | null | undefined): string {
return value ?? 'not recorded';
}
onMount(() => {
const controller = new AbortController();
void loadWorkspace(controller.signal);
void loadHosts(controller.signal);
void loadWorkers(controller.signal);
void loadRepositories(controller.signal);
void loadObjectives(controller.signal);
return () => controller.abort();
});
$effect(() => {
if (route.page === 'repository') {
void loadRepository(route.repositoryId);
void loadRepositoryTickets(route.repositoryId);
} else {
repository = null;
repositoryTickets = null;
repositoryError = null;
repositoryTicketsError = null;
}
});
$effect(() => {
const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null;
if (selectedObjectiveId) {
void loadObjectiveDetail(selectedObjectiveId);
} else {
objectiveDetailRequest += 1;
objectiveDetail = null;
objectiveDetailError = null;
objectiveDetailLoading = false;
}
});
</script>
<svelte:head>
<title>Yoi Workspace Control Plane</title>
<meta
name="description"
content="Local single-workspace Yoi control plane bootstrap"
/>
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {repositories} {repositoriesError} {currentPath} />
<main class="shell">
{#if route.page === 'repository'}
<section class="card">
<h2>Repository summary</h2>
{#if repository}
<dl>
<div>
<dt>ID</dt>
<dd><code>{repository.id}</code></dd>
</div>
<div>
<dt>Kind</dt>
<dd>{repository.kind}</dd>
</div>
<div>
<dt>Provider</dt>
<dd>{repository.provider}</dd>
</div>
<div>
<dt>Default selector</dt>
<dd>{repository.default_selector ?? 'none configured'}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{repository.record_authority}</dd>
</div>
<div>
<dt>Git</dt>
<dd>{repository.git?.status ?? 'not available'}</dd>
</div>
{#if repository.diagnostics && repository.diagnostics.length > 0}
<div>
<dt>Diagnostics</dt>
<dd>
<ul>
{#each repository.diagnostics as diagnostic}
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
{/each}
</ul>
</dd>
</div>
{/if}
</dl>
{:else if repositoryError}
<p class="error">{repositoryError}</p>
{:else}
<p>Waiting for <code>/api/repositories/{route.repositoryId}</code></p>
{/if}
</section>
<section class="card">
<h2>Repository Ticket Kanban</h2>
<p class="section-note">
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
</p>
{#if repositoryTickets}
<RepositoryTicketKanban tickets={repositoryTickets} />
{:else if repositoryTicketsError}
<p class="error">{repositoryTicketsError}</p>
{:else}
<p>Waiting for <code>/api/repositories/{route.repositoryId}/tickets</code></p>
{/if}
</section>
{:else if route.page === 'objectives' || route.page === 'objective'}
<section class="card">
<h2>Objectives</h2>
<p class="section-note">
Objectives are read from canonical filesystem records through <code>/api/objectives</code>.
</p>
{#if objectives}
{#if objectives.items.length === 0}
<p>No Objective records are present.</p>
{:else}
<div class="objective-list">
{#each objectives.items as objective (objective.id)}
<a class="objective-row" class:selected={route.page === 'objective' && route.objectiveId === objective.id} href={`/objectives/${objective.id}`}>
<div class="objective-main">
<div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong>
<span class="state-pill">{objective.state}</span>
</div>
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
</div>
<div class="objective-meta" aria-label="Objective metadata">
<span>Updated {formatDate(objective.updated_at)}</span>
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
<code>{objective.id}</code>
</div>
</a>
{/each}
</div>
{/if}
{#if objectives.invalid_records.length > 0}
<p class="error">{objectives.invalid_records.length} invalid objective record(s) hidden.</p>
{/if}
{:else if objectivesError}
<p class="error">{objectivesError}</p>
{:else}
<p>Waiting for <code>/api/objectives</code></p>
{/if}
</section>
{#if route.page === 'objective'}
<section class="card">
<h2>Objective detail</h2>
{#if objectiveDetail}
<div class="detail-heading">
<h3>{objectiveDetail.title}</h3>
<span>{objectiveDetail.state}</span>
</div>
<dl>
<div>
<dt>ID</dt>
<dd><code>{objectiveDetail.id}</code></dd>
</div>
<div>
<dt>Updated</dt>
<dd>{formatDate(objectiveDetail.updated_at)}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{formatDate(objectiveDetail.created_at)}</dd>
</div>
<div>
<dt>Linked tickets</dt>
<dd>{objectiveDetail.linked_tickets.length ? objectiveDetail.linked_tickets.join(', ') : 'none'}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{objectiveDetail.record_source}</dd>
</div>
</dl>
{#if objectiveDetail.body_truncated}
<p class="section-note">Objective body was truncated by the backend response limit.</p>
{/if}
<pre class="record-body">{objectiveDetail.body || 'No Objective body text is available.'}</pre>
{:else if objectiveDetailError}
<p class="error">{objectiveDetailError}</p>
{:else if objectiveDetailLoading}
<p>Loading Objective <code>{route.objectiveId}</code></p>
{:else}
<p>Waiting for Objective detail…</p>
{/if}
</section>
{/if}
{:else}
<section class="card">
<h2>Workspace</h2>
{#if workspace}
<dl>
<div>
<dt>ID</dt>
<dd>{workspace.workspace_id}</dd>
</div>
<div>
<dt>Name</dt>
<dd>{workspace.display_name}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{workspace.record_authority}</dd>
</div>
<div>
<dt>Host / Worker bridge</dt>
<dd>{workspace.extension_points.host_worker_bridge.status}</dd>
</div>
</dl>
{:else if workspaceError}
<p class="error">{workspaceError}</p>
{:else}
<p>Waiting for <code>/api/workspace</code></p>
{/if}
</section>
<section class="grid runtime">
<div class="card">
<h2>Hosts</h2>
{#if hosts}
{#if hosts.items.length === 0}
<p>No local Hosts are visible.</p>
{:else}
<div class="stack">
{#each hosts.items as host}
<article class="runtime-card">
<div class="runtime-heading">
<strong>{host.label}</strong>
<span class:warn={host.status !== 'available'}>{host.status}</span>
</div>
<dl>
<div>
<dt>ID</dt>
<dd><code>{host.host_id}</code></dd>
</div>
<div>
<dt>Kind</dt>
<dd>{host.kind}</dd>
</div>
<div>
<dt>Runtime</dt>
<dd><code>{host.runtime_id}</code></dd>
</div>
<div>
<dt>Scope</dt>
<dd>{host.capabilities.workspace_scope}</dd>
</div>
<div>
<dt>Platform</dt>
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
</div>
</dl>
</article>
{/each}
</div>
{/if}
{:else if hostsError}
<p class="error">{hostsError}</p>
{:else}
<p>Waiting for <code>/api/hosts</code></p>
{/if}
</div>
<div class="card">
<h2>Workers</h2>
{#if workers}
{#if workers.items.length === 0}
<p>No local Workers are visible.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Worker</th>
<th>Host</th>
<th>State</th>
<th>Workspace</th>
<th>Implementation</th>
<th>Attach</th>
</tr>
</thead>
<tbody>
{#each workers.items as worker}
<tr>
<td>
<strong>{worker.label}</strong>
{#if worker.role || worker.profile}
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
{/if}
</td>
<td><code>{worker.host_id}</code></td>
<td>{worker.state} · {worker.status}</td>
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
<td>{worker.implementation.kind}</td>
<td><a class="inline-link" href={workerConsoleHref(worker)}>Open Console</a></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else if workersError}
<p class="error">{workersError}</p>
{:else}
<p>Waiting for <code>/api/workers</code></p>
{/if}
</div>
</section>
{/if}
</main>
</div>

View File

@ -6,6 +6,7 @@ export type Diagnostic = {
export type SettingsSectionId =
| "runtime-connections"
| "profile-sources"
| "backend-config"
| "workspace-identity";
@ -84,6 +85,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
],
},
{
id: "profile-sources",
label: "Profile Sources",
status: "editable",
summary:
"Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.",
bullets: [
"Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.",
"Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.",
"Launch profile candidates refresh from the same Backend projection after registry or source updates.",
],
},
{
id: "backend-config",
label: "Backend Config",

View File

@ -0,0 +1,81 @@
import { workspaceApiJson, workspaceApiJsonWithBody } from "../workspace-api/http";
import type {
ProfileSettingsMutationResponse,
ProfileSettingsResponse,
WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse,
WorkspaceProfileSourceDetailResponse,
} from "./profile-types";
export function fetchWorkspaceMetadataSettings(workspaceId: string): Promise<WorkspaceMetadataSettingsResponse> {
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`);
}
export function updateWorkspaceMetadataSettings(
workspaceId: string,
request: { display_name: string; revision: string },
): Promise<WorkspaceMetadataMutationResponse> {
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`);
}
export function createProfileSource(
workspaceId: string,
request: { name: string; description?: string; content: string; registry_revision: string },
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, {
method: "POST",
body: JSON.stringify(request),
});
}
export function updateProfileRegistry(
workspaceId: string,
request: {
registry_revision: string;
default_profile?: string | null;
profiles: Array<{ name: string; description?: string | null; profile_source_id?: string | null }>;
},
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, {
method: "PUT",
body: JSON.stringify(request),
});
}
export function fetchProfileSource(
workspaceId: string,
sourceId: string,
): Promise<WorkspaceProfileSourceDetailResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
);
}
export function updateProfileSource(
workspaceId: string,
sourceId: string,
request: { content: string; revision: string },
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
{ method: "PUT", body: JSON.stringify(request) },
);
}
export function deleteProfileSource(
workspaceId: string,
sourceId: string,
request: { registry_revision: string; source_revision: string },
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
{ method: "DELETE", body: JSON.stringify(request) },
);
}

View File

@ -0,0 +1,60 @@
import type { Diagnostic } from "./model";
export type WorkspaceMetadataSettingsResponse = {
workspace_id: string;
display_name: string;
created_at: string;
revision: string;
source: string;
diagnostics: Diagnostic[];
};
export type WorkspaceMetadataMutationResponse = {
workspace: WorkspaceMetadataSettingsResponse;
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSummary = {
profile_id: string;
selector: string;
label: string;
source_kind: "builtin" | "project" | string;
profile_source_id?: string | null;
description?: string | null;
editable: boolean;
is_default: boolean;
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSourceSummary = {
profile_source_id: string;
display_path: string;
kind: "decodal" | string;
editable: boolean;
revision: string;
size_bytes: number;
diagnostics: Diagnostic[];
};
export type ProfileSettingsResponse = {
workspace_id: string;
registry_revision: string;
default_profile?: string | null;
profiles: WorkspaceProfileSummary[];
sources: WorkspaceProfileSourceSummary[];
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSourceDetailResponse = {
workspace_id: string;
profile: WorkspaceProfileSummary;
source: WorkspaceProfileSourceSummary;
content: string;
diagnostics: Diagnostic[];
};
export type ProfileSettingsMutationResponse = {
workspace_id: string;
settings: ProfileSettingsResponse;
diagnostics: Diagnostic[];
};

View File

@ -1,9 +1,13 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace-api/http';
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/' }: Props = $props();
let { currentPath = '/', workspaceId }: Props = $props();
let objectivesHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/objectives') : '/objectives');
</script>
<section class="nav-section">
@ -11,7 +15,7 @@
<span>Objectives</span>
</header>
<a class="objective-link" class:active={currentPath.startsWith('/objectives')} href="/objectives">
<a class="objective-link" class:active={currentPath.startsWith(objectivesHref)} href={objectivesHref}>
<span class="item-title">Open Objectives</span>
<span class="item-meta">workspace objectives</span>
</a>

View File

@ -6,10 +6,11 @@
repositories: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
workspaceId: string;
};
let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
let { repositories, repositoriesError = null, currentPath = '/', workspaceId }: Props = $props();
let navigation = $derived(projectRepositoryNav(repositories, currentPath, workspaceId));
</script>
<section class="nav-section" aria-labelledby="repositories-heading">

View File

@ -1,8 +1,11 @@
<script lang="ts">
import { workspaceApiPath } from '$lib/workspace-api/http';
import { workerConsoleHref } from '$lib/workspace-console/model';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
import type {
BrowserCreateWorkerResponse,
BrowserWorkingDirectoryCreateResponse,
Diagnostic,
ListResponse,
Worker,
WorkerLaunchOptionsResponse,
@ -12,9 +15,25 @@
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/' }: Props = $props();
type DisplayError = {
message: string;
diagnostics: Diagnostic[];
};
type ErrorPayload = {
error?: { message?: string; code?: string } | string;
message?: string;
diagnostics?: Diagnostic[];
};
let { currentPath = '/', workspaceId }: Props = $props();
function workerApiPath(path: string): string {
return workspaceApiPath(workspaceId, path);
}
let loading = $state(true);
let error = $state<string | null>(null);
@ -24,13 +43,25 @@
let optionsError = $state<string | null>(null);
let showNewWorker = $state(false);
let submitting = $state(false);
let submitError = $state<string | null>(null);
let submitError = $state<DisplayError | null>(null);
let displayName = $state('Coding Worker');
let runtimeId = $state('');
let profile = $state('builtin:coder');
let initialText = $state('');
let workingDirectoryId = $state('');
let workingDirectoryRepositoryId = $state('');
let workingDirectorySelector = $state('HEAD');
let relativeCwd = $state('');
let creatingWorkingDirectory = $state(false);
$effect(() => {
if (!workspaceId) {
loading = false;
workers = [];
options = null;
return;
}
const controller = new AbortController();
void loadWorkers(controller.signal);
void loadLaunchOptions(controller.signal);
@ -42,7 +73,7 @@
error = null;
placeholder = null;
try {
const response = await fetch('/api/workers', { signal });
const response = await fetch(workerApiPath('/workers'), { signal });
if (response.status === 404) {
workers = [];
placeholder = 'Worker API is not integrated in this build yet.';
@ -72,7 +103,7 @@
async function loadLaunchOptions(signal?: AbortSignal) {
optionsError = null;
try {
const response = await fetch('/api/workers/launch-options', { signal });
const response = await fetch(workerApiPath('/workers/launch-options'), { signal });
if (!response.ok) {
throw new Error(`worker launch options failed (${response.status})`);
}
@ -83,10 +114,18 @@
display_name: displayName,
profile,
initial_text: initialText,
working_directory_id: workingDirectoryId,
working_directory_repository_id: workingDirectoryRepositoryId,
working_directory_selector: workingDirectorySelector,
relative_cwd: relativeCwd,
});
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
workingDirectoryId = form.working_directory_id;
workingDirectoryRepositoryId = form.working_directory_repository_id;
workingDirectorySelector = form.working_directory_selector;
relativeCwd = form.relative_cwd;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
@ -95,11 +134,50 @@
}
}
async function createWorkingDirectory() {
if (!workingDirectoryRepositoryId) {
submitError = { message: 'select a repository before creating a working directory', diagnostics: [] };
return;
}
creatingWorkingDirectory = true;
submitError = null;
try {
const response = await fetch(workerApiPath('/working-directories'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repository_id: workingDirectoryRepositoryId,
selector: workingDirectorySelector || null,
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
}),
});
if (!response.ok) {
submitError = await responseDisplayError(response, 'working directory create failed');
return;
}
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse;
const items = options?.working_directories ?? [];
options = options
? { ...options, working_directories: [...items.filter((item) => item.working_directory_id !== payload.item.working_directory_id), payload.item] }
: options;
workingDirectoryId = payload.item.working_directory_id;
} catch (err) {
submitError = exceptionDisplayError(err, 'working directory create failed');
} finally {
creatingWorkingDirectory = false;
}
}
async function createWorker() {
if (!workspaceId) {
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
return;
}
submitError = null;
submitting = true;
try {
const response = await fetch('/api/workers', {
const response = await fetch(workerApiPath('/workers'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(buildBrowserCreateWorkerRequest({
@ -107,35 +185,51 @@
display_name: displayName,
profile,
initial_text: initialText,
working_directory_id: workingDirectoryId,
working_directory_repository_id: workingDirectoryRepositoryId,
working_directory_selector: workingDirectorySelector,
relative_cwd: relativeCwd,
})),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'worker create failed'));
submitError = await responseDisplayError(response, 'worker create failed');
return;
}
const payload = (await response.json()) as BrowserCreateWorkerResponse;
await loadWorkers();
window.location.href = payload.console_href;
} catch (err) {
submitError = err instanceof Error ? err.message : 'worker create failed';
submitError = exceptionDisplayError(err, 'worker create failed');
} finally {
submitting = false;
}
}
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
async function responseDisplayError(response: Response, fallback: string): Promise<DisplayError> {
try {
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
const payload = (await response.json()) as ErrorPayload;
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
if (typeof payload.error === 'object' && payload.error?.message) {
return `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`;
return {
message: `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`,
diagnostics,
};
}
if (payload.message) {
const code = typeof payload.error === 'string' ? payload.error : 'request_failed';
return `${code}: ${payload.message}`;
return { message: `${code}: ${payload.message}`, diagnostics };
}
} catch {
// fall through
}
return `${fallback} (${response.status})`;
return { message: `${fallback} (${response.status})`, diagnostics: [] };
}
function exceptionDisplayError(err: unknown, fallback: string): DisplayError {
return {
message: err instanceof Error ? err.message : fallback,
diagnostics: [],
};
}
</script>
@ -182,6 +276,43 @@
{/if}
</select>
</label>
<fieldset class="worker-working-directory">
<legend>Working directory</legend>
<label>
<span>Working directory</span>
<select bind:value={workingDirectoryId}>
<option value="">No working directory selected</option>
{#each options?.working_directories ?? [] as directory}
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'} · {directory.resolved_commit.slice(0, 12)} · {directory.status}
</option>
{/each}
</select>
</label>
<label>
<span>Repository</span>
<select bind:value={workingDirectoryRepositoryId}>
{#if options?.repositories.length}
{#each options.repositories as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
{:else}
<option value="" disabled>No configured repositories</option>
{/if}
</select>
</label>
<label>
<span>Selector</span>
<input bind:value={workingDirectorySelector} autocomplete="off" placeholder="HEAD" />
</label>
<button type="button" disabled={creatingWorkingDirectory || !workingDirectoryRepositoryId} onclick={() => void createWorkingDirectory()}>
{creatingWorkingDirectory ? 'Creating…' : 'Create working directory'}
</button>
<label>
<span>Relative cwd</span>
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside working directory" />
</label>
</fieldset>
<label>
<span>Initial text</span>
<textarea bind:value={initialText} rows="3" placeholder="Optional first instruction"></textarea>
@ -190,9 +321,21 @@
<p class="section-state error">{optionsError}</p>
{/if}
{#if submitError}
<p class="section-state error">{submitError}</p>
<div class="section-state error worker-submit-error">
<p>{submitError.message}</p>
{#if submitError.diagnostics.length > 0}
<ul class="worker-error-diagnostics">
{#each submitError.diagnostics as diagnostic}
<li class={diagnostic.severity}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<button type="submit" disabled={submitting || !runtimeId || !profile}>
<button type="submit" disabled={submitting || !runtimeId || !profile || !workingDirectoryId}>
{submitting ? 'Starting…' : 'Start Coding Worker'}
</button>
</form>
@ -207,7 +350,7 @@
{:else}
<ul class="nav-list" aria-label="Workers">
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker)}
{@const href = workerConsoleHref(worker, workspaceId)}
<li>
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
<span class="worker-title-row">
@ -216,6 +359,7 @@
</span>
<span class="item-meta">
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
</span>
</a>
</li>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { workspaceRoute } from '$lib/workspace-api/http';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
@ -11,84 +11,102 @@
repositories?: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
collapsed?: boolean;
onToggleCollapsed?: () => void;
};
let {
workspace,
workspaceError = null,
repositories,
repositoriesError,
currentPath = '/'
repositories = null,
repositoriesError = null,
currentPath = '/',
collapsed = false,
onToggleCollapsed
}: Props = $props();
let fallbackRepositories = $state<RepositoryListResponse | null>(null);
let fallbackRepositoriesError = $state<string | null>(null);
let displayedRepositories = $derived(repositories === undefined ? fallbackRepositories : repositories);
let displayedRepositoriesError = $derived(
repositoriesError === undefined ? fallbackRepositoriesError : repositoriesError
);
onMount(() => {
if (repositories !== undefined) {
return;
}
const controller = new AbortController();
void loadFallbackRepositories(controller.signal);
return () => controller.abort();
});
async function loadFallbackRepositories(signal?: AbortSignal) {
fallbackRepositoriesError = null;
try {
const response = await fetch('/api/repositories', { signal });
if (!response.ok) {
throw new Error(`repositories request failed (${response.status})`);
}
fallbackRepositories = (await response.json()) as RepositoryListResponse;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
fallbackRepositoriesError = error instanceof Error ? error.message : 'repositories request failed';
fallbackRepositories = null;
}
}
let workspaceId = $derived(workspace?.workspace_id ?? '');
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
</script>
<aside class="workspace-sidebar" aria-label="Workspace navigation">
<aside
class:collapsed
class="workspace-sidebar"
aria-label="Workspace navigation"
>
<header class="sidebar-header">
<div class="workspace-label">
{#if workspace}
<p class="workspace-status">{workspace.workspace_id}</p>
<h1>{workspace.display_name}</h1>
{:else}
<h1>Yoi workspace</h1>
{#if workspaceError}
<p class="workspace-status error">Workspace summary unavailable.</p>
<div class="sidebar-title-row">
<div class="workspace-label">
{#if workspace}
<div class="workspace-name">{workspace.display_name}</div>
{:else}
<p class="workspace-status">Loading workspace…</p>
<div class="workspace-name">Yoi workspace</div>
{#if workspaceError}
<p class="workspace-status error">Workspace summary unavailable.</p>
{:else}
<p class="workspace-status">Loading workspace…</p>
{/if}
{/if}
{/if}
</div>
<button
class="sidebar-collapse-button"
type="button"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!collapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onclick={onToggleCollapsed}
>
{#if collapsed}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
</button>
</div>
<a
class="settings-button"
href="/settings"
aria-label="Open Settings / Admin"
title="Settings / Admin"
>
</a>
<div class="sidebar-actions-row">
<a
class="sidebar-icon-button"
href={homeHref}
aria-label="Open workspace overview"
title="Workspace overview"
>
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path
d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
/>
</svg>
</a>
<a
class="sidebar-icon-button"
href={settingsHref}
aria-label="Open Settings / Admin"
title="Settings / Admin"
>
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
/>
<circle cx="12" cy="12" r="4" />
</svg>
</a>
</div>
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection
repositories={displayedRepositories}
repositoriesError={displayedRepositoriesError}
{currentPath}
/>
<ObjectivesNavSection {currentPath} />
<WorkersNavSection {currentPath} />
</nav>
{#if !collapsed}
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
{/if}
</aside>

View File

@ -36,7 +36,7 @@ Deno.test("repository nav does not invent main for an empty registry", () => {
message: "No repositories configured",
},
],
});
}, "/w/workspace-1", "workspace-1");
assertEquals(projection.count, 0);
assertEquals(projection.items, []);
@ -56,14 +56,15 @@ Deno.test("repository nav links configured non-main repository ids", () => {
diagnostics: [],
},
]),
"/repositories/infra",
"/w/workspace-1/repositories/infra",
"workspace-1",
);
assertEquals(projection.count, 1);
assertEquals(projection.items[0], {
id: "infra",
title: "Infrastructure",
href: "/repositories/infra",
href: "/w/workspace-1/repositories/infra",
meta: "git repository · read-only",
active: true,
});

View File

@ -1,3 +1,4 @@
import { workspaceRoute } from "$lib/workspace-api/http";
import type { Diagnostic, RepositoryListResponse } from "./types";
export type RepositoryNavItem = {
@ -16,14 +17,15 @@ export type RepositoryNavProjection = {
export function projectRepositoryNav(
repositories: RepositoryListResponse | null,
currentPath = "/",
currentPath: string,
workspaceId: string,
): RepositoryNavProjection {
const summaries = repositories?.items ?? [];
return {
count: summaries.length,
diagnostics: repositories?.diagnostics ?? [],
items: summaries.map((repository) => {
const href = `/repositories/${encodeURIComponent(repository.id)}`;
const href = workspaceRoute(workspaceId, `/repositories/${encodeURIComponent(repository.id)}`);
return {
id: repository.id,
title: repository.display_name || repository.id,

View File

@ -1,8 +1,8 @@
import type {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment
} from '$lib/generated/protocol';
Segment as PodProtocolSegment,
} from "$lib/generated/protocol";
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
@ -88,10 +88,11 @@ export type Worker = {
last_seen_at?: string | null;
implementation: { kind: string; display_hint: string };
capabilities: WorkerCapabilities;
working_directory?: WorkingDirectorySummary | null;
diagnostics: Diagnostic[];
};
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
export type WorkerLaunchRuntimeOption = {
runtime_id: string;
@ -108,10 +109,61 @@ export type WorkerLaunchProfileCandidate = {
description: string;
};
export type WorkingDirectoryRepositoryOption = {
id: string;
display_name: string;
default_selector?: string | null;
};
export type WorkingDirectorySummary = {
working_directory_id: string;
repository_id: string;
requested_selector?: string | null;
materializer_kind: string;
dirty_state_policy: string;
resolved_commit: string;
resolved_tree?: string | null;
status: string;
cleanup_policy: string;
cleanup_target: {
kind: string;
working_directory_id: string;
repository_id: string;
};
};
export type BrowserWorkingDirectoryCreateResponse = {
workspace_id: string;
item: WorkingDirectorySummary;
diagnostics: Diagnostic[];
};
export type BrowserWorkingDirectoryListResponse = {
workspace_id: string;
items: WorkingDirectorySummary[];
diagnostics: Diagnostic[];
};
export type BrowserWorkerWorkingDirectorySelection = {
working_directory_id: string;
relative_cwd?: string | null;
};
export type BrowserWorkingDirectoryCreateRequest = {
repository_id: string;
selector?: string | null;
policy?: {
dirty_state?: "clean_point_only";
cleanup?: "manual_or_worker_stop";
};
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
profiles: WorkerLaunchProfileCandidate[];
repositories: WorkingDirectoryRepositoryOption[];
working_directories: WorkingDirectorySummary[];
diagnostics: Diagnostic[];
};
@ -135,7 +187,7 @@ export type WorkerInputResult = {
export type WorkerTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
role: "user" | "assistant" | "system" | string;
content: string;
event_id: number;
};
@ -166,8 +218,8 @@ export type ClientWorkerEventWsDiagnostic = {
};
export type ClientWorkerEventWsFrame =
| { kind: 'event'; envelope: ClientWorkerEventWsEnvelope }
| { kind: 'diagnostic'; diagnostic: ClientWorkerEventWsDiagnostic };
| { kind: "event"; envelope: ClientWorkerEventWsEnvelope }
| { kind: "diagnostic"; diagnostic: ClientWorkerEventWsDiagnostic };
export type ListResponse<T> = {
workspace_id: string;
@ -297,13 +349,13 @@ export type ObjectiveListResponse = {
};
export type CompanionState =
| 'ready'
| 'busy'
| 'error'
| 'timeout'
| 'cancelled'
| 'accepted'
| 'rejected';
| "ready"
| "busy"
| "error"
| "timeout"
| "cancelled"
| "accepted"
| "rejected";
export type CompanionTransportSummary = {
kind: string;
@ -320,7 +372,7 @@ export type CompanionStatusResponse = {
export type CompanionTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
role: "user" | "assistant" | "system" | string;
content: string;
created_at: string;
source: string;

View File

@ -1,83 +1,109 @@
import {
buildBrowserCreateWorkerRequest,
defaultWorkerLaunchForm,
type WorkerLaunchFormState,
} from './worker-launch.ts';
import type { WorkerLaunchOptionsResponse } from './types.ts';
} from "./worker-launch.ts";
import type { WorkerLaunchOptionsResponse } from "./types.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
test(name: string, fn: () => Promise<void> | void): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
function assertEquals<T>(actual: T, expected: T): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
}
}
const options: WorkerLaunchOptionsResponse = {
workspace_id: 'workspace',
workspace_id: "workspace",
runtimes: [
{
runtime_id: 'remote-runtime',
display_name: 'Remote Runtime',
runtime_id: "remote",
display_name: "Remote",
status: "active",
can_spawn_worker: true,
built_in: false,
can_spawn_worker: false,
status: 'active',
diagnostics: [],
},
{
runtime_id: 'embedded-worker-runtime',
display_name: 'Embedded Runtime',
built_in: true,
runtime_id: "embedded",
display_name: "Embedded",
status: "active",
can_spawn_worker: true,
status: 'active',
built_in: false,
diagnostics: [],
},
],
profiles: [
{ id: "builtin:companion", label: "Companion", description: "chat" },
{ id: "builtin:coder", label: "Coder", description: "code" },
],
repositories: [
{ id: "repo", display_name: "Repo", default_selector: "HEAD" },
],
working_directories: [
{
id: 'runtime_default',
label: 'Runtime default',
description: 'Runtime default profile.',
},
{
id: 'builtin:coder',
label: 'Coding Worker',
description: 'Coding role.',
working_directory_id: "wd-1-repo",
repository_id: "repo",
requested_selector: "HEAD",
materializer_kind: "local_git_worktree",
dirty_state_policy: "clean_point_only",
resolved_commit: "0123456789abcdef",
status: "active",
cleanup_policy: "manual_or_worker_stop",
cleanup_target: {
kind: "git_worktree",
working_directory_id: "wd-1-repo",
repository_id: "repo",
},
},
],
diagnostics: [],
};
Deno.test('new worker form defaults to backend-published runtime and profile candidates', () => {
const current: WorkerLaunchFormState = {
runtime_id: '',
display_name: '',
profile: 'free-text-profile',
initial_text: 'start here',
};
const form = defaultWorkerLaunchForm(options, current);
assert(form.runtime_id === 'embedded-worker-runtime', 'should choose spawn-capable runtime');
assert(form.profile === 'builtin:coder', 'should choose backend-published coder profile');
assert(form.display_name === 'Coding Worker', 'should derive default display name');
assert(form.initial_text === 'start here', 'should preserve initial text');
});
Deno.test('new worker submit payload exposes only browser contract fields', () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: 'embedded-worker-runtime',
display_name: 'Coding Worker',
profile: 'builtin:coder',
initial_text: 'implement ticket',
Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, repository, and working directory", () => {
const form = defaultWorkerLaunchForm(options, {
runtime_id: "",
display_name: "",
profile: "",
initial_text: "hello",
working_directory_id: "",
working_directory_repository_id: "",
working_directory_selector: "",
relative_cwd: "",
});
assert(
JSON.stringify(Object.keys(request).sort()) ===
JSON.stringify(['display_name', 'initial_text', 'profile', 'runtime_id'].sort()),
'submit payload should contain only Browser-facing worker create fields',
);
assert(!('kind' in request), 'kind must not be exposed as a Browser request field');
assertEquals(form.runtime_id, "remote");
assertEquals(form.display_name, "Coding Worker");
assertEquals(form.profile, "builtin:coder");
assertEquals(form.initial_text, "hello");
assertEquals(form.working_directory_id, "wd-1-repo");
assertEquals(form.working_directory_repository_id, "repo");
assertEquals(form.working_directory_selector, "HEAD");
});
Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: "embedded",
display_name: "Worker",
profile: "builtin:coder",
initial_text: "go",
working_directory_id: "wd-1-repo",
working_directory_repository_id: "repo",
working_directory_selector: "main",
relative_cwd: "crates/yoi",
});
assertEquals(request, {
runtime_id: "embedded",
display_name: "Worker",
profile: "builtin:coder",
initial_text: "go",
working_directory: {
working_directory_id: "wd-1-repo",
relative_cwd: "crates/yoi",
},
});
});

View File

@ -1,41 +1,90 @@
import type { WorkerLaunchOptionsResponse } from './types';
import type {
BrowserWorkerWorkingDirectorySelection,
WorkerLaunchOptionsResponse,
} from "./types";
export type WorkerLaunchFormState = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
working_directory_id: string;
working_directory_repository_id: string;
working_directory_selector: string;
relative_cwd: string;
};
export type BrowserCreateWorkerRequest = WorkerLaunchFormState;
export type BrowserCreateWorkerRequest = {
runtime_id: string;
display_name: string;
profile: string;
initial_text: string;
working_directory?: BrowserWorkerWorkingDirectorySelection;
};
export function defaultWorkerLaunchForm(
options: WorkerLaunchOptionsResponse | null,
current: WorkerLaunchFormState,
): WorkerLaunchFormState {
const preferredRuntime = options?.runtimes.find((runtime) => runtime.can_spawn_worker && runtime.status === 'active')
?? options?.runtimes.find((runtime) => runtime.can_spawn_worker)
?? options?.runtimes[0];
const preferredProfile = options?.profiles.find((candidate) => candidate.id === 'builtin:coder')
?? options?.profiles[0];
const preferredRuntime =
options?.runtimes.find((runtime) =>
runtime.can_spawn_worker && runtime.status === "active"
) ??
options?.runtimes.find((runtime) => runtime.can_spawn_worker) ??
options?.runtimes[0];
const preferredProfile =
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
options?.profiles[0];
const preferredWorkingDirectory =
options?.working_directories.find((directory) =>
directory.status === "active"
) ??
options?.working_directories[0];
const preferredRepository =
options?.repositories.find((repository) =>
repository.id === current.working_directory_repository_id
) ??
options?.repositories[0];
return {
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || '',
display_name: current.display_name || 'Coding Worker',
profile: options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile
: preferredProfile?.id || '',
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
display_name: current.display_name || "Coding Worker",
profile:
options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile
: preferredProfile?.id || "",
initial_text: current.initial_text,
working_directory_id: options?.working_directories.some(
(directory) =>
directory.working_directory_id === current.working_directory_id,
)
? current.working_directory_id
: preferredWorkingDirectory?.working_directory_id || "",
working_directory_repository_id: current.working_directory_repository_id ||
preferredRepository?.id || "",
working_directory_selector: current.working_directory_selector ||
preferredRepository?.default_selector || "HEAD",
relative_cwd: current.relative_cwd,
};
}
export function buildBrowserCreateWorkerRequest(
form: WorkerLaunchFormState,
): BrowserCreateWorkerRequest {
return {
const request: BrowserCreateWorkerRequest = {
runtime_id: form.runtime_id,
display_name: form.display_name,
profile: form.profile,
initial_text: form.initial_text,
};
if (form.working_directory_id) {
request.working_directory = {
working_directory_id: form.working_directory_id,
};
const relativeCwd = form.relative_cwd.trim();
if (relativeCwd) {
request.working_directory.relative_cwd = relativeCwd;
}
}
return request;
}

View File

@ -1,7 +1,24 @@
<script lang="ts">
import { page } from '$app/state';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import '../app.css';
import type { LayoutProps } from './$types';
let { children } = $props();
let { data, children }: LayoutProps = $props();
let sidebarCollapsed = $state(false);
</script>
{@render children()}
<div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
<WorkspaceSidebar
workspace={data.workspace}
workspaceError={data.workspaceError}
repositories={data.repositories}
repositoriesError={data.repositoriesError}
currentPath={page.url.pathname}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => (sidebarCollapsed = !sidebarCollapsed)}
/>
<main class="shell">
{@render children()}
</main>
</div>

View File

@ -1,2 +1,42 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace-sidebar/types";
import type { LayoutLoad } from "./$types";
export const ssr = false;
export const prerender = false;
export const load: LayoutLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId;
if (!workspaceId) {
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const scopedPath = workspaceRoute(
workspace.data.workspace_id,
url.pathname === "/" ? "" : url.pathname,
);
throw redirect(307, `${scopedPath}${url.search}`);
}
return {
workspace: null,
workspaceError: workspace.error,
repositories: null,
repositoriesError: null,
};
}
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
]);
return {
workspace: workspace.data,
workspaceError: workspace.error,
repositories: repositories.data,
repositoriesError: repositories.error,
};
};

View File

@ -1,5 +1,6 @@
<script lang="ts">
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
</script>
<WorkspacePage view="overview" />
<main class="workspace-panel-shell">
<section class="workspace-card">
<h1>Redirecting to scoped workspace…</h1>
<p class="section-note">This compatibility route bootstraps the current workspace id and redirects to the canonical <code>/w/&lt;workspace-id&gt;</code> route.</p>
</section>
</main>

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