Compare commits

...

18 Commits

Author SHA1 Message Date
b6029220ff
fix: refine workspace runtime UI 2026-07-06 22:51:25 +09:00
5279f8fc0d
merge: repository registry 2026-07-05 23:51:54 +09:00
8528c9057a
ticket: close repository registry work 2026-07-05 05:19:21 +09:00
a786fd85a7
merge: repository registry implementation 2026-07-05 05:18:01 +09:00
b0334e042b
ticket: approve repository registry implementation 2026-07-05 05:18:01 +09:00
7163ba7197
ticket: record repository registry review fixes 2026-07-05 05:11:36 +09:00
14e63ca5b6
fix: derive repository UI from registry 2026-07-05 05:10:23 +09:00
a62653e5bc
ticket: record repository registry review blockers 2026-07-05 05:03:00 +09:00
6717ced46a
ticket: record repository registry implementation evidence 2026-07-05 04:58:57 +09:00
2f0d1cee19
feat: add workspace repository registry 2026-07-05 04:57:52 +09:00
6c2f2e0df3
ticket: route repository registry coder 2026-07-05 04:37:01 +09:00
416d67ca06
ticket: accept repository registry work 2026-07-05 04:35:55 +09:00
f4d7631f19
ticket: queue 00001KWPC13WQ 2026-07-05 04:34:04 +09:00
23d3782f38
ticket: ready repository registry 2026-07-04 19:59:11 +09:00
078f2137b3
docs: update workspace runtime objective 2026-07-04 19:58:50 +09:00
1ebdf7cf7a
ticket: add repository registry work 2026-07-04 19:51:33 +09:00
b3dac8ea68
feat: live reload runtime connections 2026-07-04 04:47:23 +09:00
296da3a440
ticket: add runtime registry reload 2026-07-04 01:00:28 +09:00
39 changed files with 2828 additions and 956 deletions

View File

@ -1,35 +1,37 @@
---
title: "Team workspace control plane and runner architecture"
title: "Team workspace control plane and runtime architecture"
state: "active"
created_at: "2026-06-20T14:26:29Z"
updated_at: "2026-06-21T18:10:00Z"
linked_tickets: ["00001KVMFFYVX"]
updated_at: "2026-07-03T18:05:00Z"
linked_tickets: ["00001KVMFFYVX", "00001KWMBAA6V"]
---
## Goal
Yoi を、単一のローカル開発ディレクトリで動くエージェント実行ツールから、チームで作業・判断・実行結果を管理できるワークスペース基盤へ発展させる。
この Objective の中心は、Web から扱える管理システムを作り、その管理システムにローカル実行環境・リモート実行環境・将来のクラウド実行環境を接続できるようにすることである。管理システムは Ticket、Objective、Memory、Knowledge、Run、Artifact、Runner の正本を持つ。実行環境はその管理システムから仕事を受け取り、コード取得、作業用ディレクトリ作成、エージェント実行、検証、結果報告を行う
この Objective の中心は、Web から扱える管理システムを作り、その管理システムにローカル Runtime・リモート Runtime・将来のクラウド Runtime を接続できるようにすることである。管理システムは Ticket、Objective、Memory、Knowledge、Artifact、Policy、Actor、Repository、Runtime state の正本を持つ。Runtime はその管理システムから Worker launch request / config bundle / repository target / authority を受け取り、作業環境を用意して Worker を実行し、結果・イベント・証跡を返す
この Objective は Git ホスティングサービスを作るものではない。Git は重要な Repository provider として扱うが、Yoi の Workspace は Git Repository root と同じものにしない。Yoi が作るべきものは、コード・ドキュメント・データ・成果物などの Repository と実行環境を接続しながら、人間とエージェントの作業、Ticket lifecycle、Memory/Knowledge、検証証跡、実行環境配置を管理するチームワークスペースである。
この Objective は Git ホスティングサービスを作るものではない。Git は重要な Repository provider として扱うが、Yoi の Workspace は Git Repository root と同じものにしない。Yoi が作るべきものは、コード・ドキュメント・データ・成果物などの Repository と Runtime を接続しながら、人間とエージェントの作業、Ticket lifecycle、Memory/Knowledge、検証証跡、実行環境配置を管理するチームワークスペースである。
## Glossary
この Objective では、以下の語をこの意味で使う。
- Workspace: チームまたはプロジェクトの管理単位。Ticket、Objective、Memory、Knowledge、Run、Artifact、Policy、Actor、Repository を持つ。Git Repository root ではない。
- Workspace: チームまたはプロジェクトの管理単位。Ticket、Objective、Memory、Knowledge、Artifact、Policy、Actor、Repository、Runtime state を持つ。Git Repository root ではない。
- Control plane: Workspace の正本を持ち、Web UI / API / CLI から操作される管理システム。
- Runner: Control plane から仕事を受け取り、実際にエージェントやツールを動かす実行環境。最初はローカルマシン上の runner、後でリモート runner やクラウド runner を追加する。
- 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 の中で動く。
- 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 など。
- RepositoryPoint: Repository 内の特定地点。Git commit/ref/path、object store version/prefix、file snapshot/path など、provider ごとの revision/ref/snapshot/path を表す。
- Execution Workspace: Runner が Run のために作る作業用ディレクトリや container filesystem。1 つ以上の RepositoryPoint から materialize される。Git worktree、clone、sparse checkout などはこれを作る手段である。
- 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 などはこれを作る手段である。
- Ticket: チームで扱う作業単位。目的、要件、判断、議論、完了条件、関係、証跡を持つ。
- Objective: 複数の Ticket を束ねる長期目標や設計方針。
- Run: Ticket や Objective に対して行われた具体的な実行試行。どの Runner が、どの Execution Workspace で、何を実行し、どんな結果になったかを持つ。
- Artifact: Run や Ticket に紐づく成果物や証跡。diff、log、validation result、review result、report など。
- Memory: エージェントやユーザーが再利用するための要約された文脈。Ticket や Run の正本ではない。
- Artifact: Ticket や Worker 実行に紐づく成果物や証跡。diff、log、validation result、review result、report など。
- Memory: エージェントやユーザーが再利用するための要約された文脈。Ticket や Artifact の正本ではない。
- Knowledge: 保守された知識や設計判断。Memory より人間が維持する資料に近い。
- Actor: 人間、エージェント、システム、外部サービスなど、Workspace 上で操作や発言を行う主体。
@ -40,21 +42,21 @@ Yoi を、単一のローカル開発ディレクトリで動くエージェン
- Workspace を Git Repository root と同一視しない。
- ローカル filesystem 上の `.yoi` を、長期的なチーム用正本 store にしない。
- Ticket をローカル作業メモではなく、チームの作業調整 record にする。
- Ticket と実行試行を分ける。実行試行は Run として記録する
- 管理システムと実行環境を分ける。
- まず Web から Ticket、Objective、Memory、Knowledge、Run、Artifact、Runner state を見られるようにする。
- 最初はローカルマシンを Runner として使い、後でリモート Runner、クラウド Runner、runner pool、resource allocation、quota、billing、sandboxing に拡張する。
- 実行証跡は Ticket thread、Artifact、WorkerRef snapshot、Runtime event として扱い、独立した実行単位概念を先に増やさない
- 管理システムと 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 の手段として扱う。
OSS として Control plane、Runner、Web frontend、protocol を公開しつつ、managed service では hosted control plane、runner fleet、リソース柔軟性、team auth、backup、audit、availability、multi-tenant operations で価値を出す。
OSS として Control plane、Runtime、Web frontend、protocol を公開しつつ、managed service では hosted control plane、runtime fleet、リソース柔軟性、team auth、backup、audit、availability、multi-tenant operations で価値を出す。
## Strategy / design direction
### 1. Control plane を先に作る
Team Workspace の正本は server-side control plane に置く。`.yoi` は local backend、single-user/self-hosted compatibility、offline/export/import、runner-local projection、migration bridge として残せるが、multi-user SaaS の正本とはみなさない。
Team Workspace の正本は server-side control plane に置く。`.yoi` は local backend、single-user/self-hosted compatibility、offline/export/import、local projection、migration bridge として残せるが、multi-user SaaS の正本とはみなさない。
Control plane は Ticket、Objective、Memory、Knowledge、Run、Artifact、Actor、Permission、Audit、Runner state を管理する。Web UI、CLI、TUI、将来の desktop client は、この Control plane を操作する client であり、別の正本 store を持たない。
Control plane は Ticket、Objective、Memory、Knowledge、Artifact、Actor、Permission、Audit、Repository、Runtime / Worker state を管理する。Web UI、CLI、TUI、将来の desktop client は、この Control plane を操作する client であり、別の正本 store を持たない。
### 2. Workspace と Repository を同一視しない
@ -62,9 +64,15 @@ Workspace はチームまたはプロジェクトの作業管理単位である
1 つの Workspace は複数の Repository を持てる。Repository は filesystem path ではなく URI / URL で識別する。例として `git+https://...`、`file://...`、`s3://...`、`artifact://...`、将来の VCS provider URI などを扱えるようにする。
Ticket と Objective は Repository 配下に置かず、Workspace 配下に平たく持つ。Ticket は必要に応じて対象 Repository、ref selector、path、必要 capability を持つ。Objective は複数 Ticket にまたがる target default / scope hint を持てるが、Repository の所有物にはしない。
Ticket と Objective は Repository 配下に置かず、Workspace 配下に平たく持つ。Ticket は必要に応じて対象 RepositoryId、RepositorySelector、path scope、必要 capability を持つ。Objective は複数 Ticket にまたがる target default / scope hint を持てるが、Repository の所有物にはしない。
Run は Ticket の target selector を具体的な RepositoryPoint に解決し、その RepositoryPoint から Execution Workspace を materialize する。Git worktree 相当の機能は、この Execution 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 を作るための実装戦略として扱う。
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 として自動採用しない。
`.yoi` は現在の local backend / fs-store / compatibility surface として残るが、long-term Backend store ではない。将来的には `~/.yoi` 側に Backend store と Workspace registry を置き、1 Backend process が複数 Workspace を扱える形へ移行する。`.yoi/workspace-backend.local.toml` はその移行までの workspace-local descriptor / override surface として扱う。
短期的には Git を主な Repository provider とする。ただし Yoi の authority model を Git object、Git branch、Git Repository root、worktree path に固定しない。Orchestration は Git そのものではなく、`resolve_ref`、`materialize`、`diff`、`patch`、`commit`、`merge` などの Repository capability に依存する。
@ -72,15 +80,14 @@ Run は Ticket の target selector を具体的な RepositoryPoint に解決し
Ticket は実行そのものではない。Ticket は「何を、なぜ、どの条件で完了とみなすか」を持つ。Ticket は Workspace に平たく所属し、Repository には所属しない。コードやドキュメントを対象にする Ticket は、対象 Repository / ref selector / path / intent を target として持つ。
Ticket target は intent/selector であり、実行再現性のための immutable point ではない。Run が target selector を concrete RepositoryPoint に解決し、実際にどの revision/snapshot を materialize したかを記録する。
Ticket target は intent/selector であり、実行再現性のための immutable point ではない。Worker launch request が target selector を concrete RepositoryPoint に解決し、Runtime が実際にどの revision/snapshot を materialize したかを Artifact / evidence として記録する。
```text
Ticket
-> target selectors: Repository + ref selector + path + intent
-> Run / Attempt
-> resolved RepositoryPoint
-> Execution Workspace
-> Artifact / Evidence
-> WorkerRef / Artifact / Evidence
-> Review / Decision
-> Audit / Notification
```
@ -100,7 +107,7 @@ Ticket targets:
paths: ["docs/development/"]
intent: read
Run inputs:
Worker launch materialization:
- repository: main-code
requested_ref: develop
resolved_point: git commit abc123
@ -112,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 / Run / Repository / RepositoryPoint / Execution Workspace.
- Linked Objective / Artifact / WorkerRef / Repository / RepositoryPoint / Execution Workspace.
- Permission / visibility.
- Audit trail.
- Notification / mention.
@ -121,42 +128,66 @@ Ticket には次の概念が必要になる。
### 4. Memory / Knowledge の本格再設計は後回しにする
Memory / Knowledge は Ticket / Run / Artifact のコピーではない。再利用可能な文脈、方針、学習された制約、保守された知識として扱う。ただし、Memory の意味論・抽出・承認・検索・staleness 処理を今この Objective で先に作り込まない。
Memory / Knowledge は Ticket / Artifact のコピーではない。再利用可能な文脈、方針、学習された制約、保守された知識として扱う。ただし、Memory の意味論・抽出・承認・検索・staleness 処理を今この Objective で先に作り込まない。
理由は、Memory の正しい設計が Workspace control plane の record model、Actor / visibility / permission、Ticket と Run の分離、Artifact / evidence、RepositoryPoint、Runner に渡す context の監査方法に依存するためである。これらが固まる前に Memory schema だけを作ると、local `.yoi` 前提や現行 agent runtime 前提に引っ張られ、後で再設計が必要になる。
理由は、Memory の正しい設計が Workspace control plane の record model、Actor / visibility / permission、Ticket、Artifact / evidence、RepositoryPoint、Runtime に渡す context の監査方法に依存するためである。これらが固まる前に Memory schema だけを作ると、local `.yoi` 前提や現行 agent runtime 前提に引っ張られ、後で再設計が必要になる。
この Objective では、Memory / Knowledge について以下の platform contract だけを維持する。
- Memory / Knowledge は Control plane が扱う record だが、Ticket / Run / Artifact の authority を置き換えない。
- Memory / Knowledge は Control plane が扱う record だが、Ticket / Artifact の authority を置き換えない。
- 将来、Memory / Knowledge の canonical storage は Workspace control plane 側に置く。
- local `.yoi` memory は compatibility、offline/export/import、runner-local projection、migration bridge として扱う。
- Personal Memory、Workspace Memory、Run Summary、Maintained Knowledge は分離が必要である。
- local `.yoi` memory は compatibility、offline/export/import、local projection、migration bridge として扱う。
- Personal Memory、Workspace Memory、Worker Summary、Maintained Knowledge は分離が必要である。
- Generated Memory には provenance、visibility、approval、audit が必要である。
- Runner / agent に渡した Memory/Knowledge context は、将来 ContextPack などとして Run に記録できる必要がある。
- Runtime / Worker に渡した Memory/Knowledge context は、将来 ContextPack などとして Artifact/evidence に記録できる必要がある。
本格的な Memory 再設計は、Memory の保存先を Workspace backend / control plane record に移すタイミングで回収する。それまでは低リスクな観察、問題例の収集、既存 local memory の互換維持に留める。
### 5. 管理システムと実行環境を弱結合にする
### 5. Control plane / Runtime を分離する
Control plane は正本と調整を持つ。Runner は実行を担当する。
Control plane は正本と調整を持つ。Runtime は Worker 群と実行基盤を管理する。初期実装では local backend と runtime process が同じマシン上にあり、役割が近く見えるが、設計上は分ける。
初期形:
```text
Web UI / Control Plane
-> Runner connection
-> Local machine runner
-> Existing Yoi runtime, tools, working copy, build/test commands
-> Runtime registry / local backend
-> Runtime process
-> Workers
-> Existing Yoi tools, working copy, build/test commands
```
この段階では、現在ローカル管理画面が行っている Ticket 選択、エージェント起動、レビュー起動、作業用 checkout 作成、検証実行、結果表示を、Web/control plane から local runner に対して実行できるようにする。
この段階では、現在ローカル管理画面が行っている Ticket 選択、エージェント起動、レビュー起動、作業用 checkout 作成、検証実行、結果表示を、Web/control plane から local Runtime に対して実行できるようにする。
その後で、remote runner、self-hosted runner、hosted cloud runner、runner pool、resource allocation、quota、billing、sandbox、network policy、secret distribution を追加する。
長期的には Runtime が作業環境の用意まで請け負う。Runtime は Worker launch request / config bundle / repository target / authority を受け取り、必要な checkout、worktree、container filesystem、sandbox mount、cache、secret boundary を準備して Worker を起動する。Runtime 起動時に特定 Workspace path を必須にする形は暫定であり、Workspace / Repository 情報は Runtime process 起動引数ではなく Worker launch request 側の入力に寄せる。
Sandbox と authority 分離が成立している前提では、1 つの Runtime が複数 Workspace / Repository の Worker を抱えられる。したがって Runtime identity は Git repository root や single workspace directory と同一視しない。Runtime は execution substrate、Workspace は作業管理 record の正本として扱う。
Worker の作業環境を用意する経路は次のようにまとめる。
```text
Phase 1: Web control plane + local runner
Phase 2: Remote/self-hosted runner
Phase 3: Hosted cloud runner fleet
Ticket / user intent
-> RepositoryId + RepositorySelector + path scope + required authority
-> resolved RepositoryPoint
-> WorkerLaunchRequest / ConfigBundle / AuthorityBundle
-> Runtime ExecutionWorkspaceMaterializer
-> Execution Workspace 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 を起動する。
この境界では、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 として公開する。
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 ではない。
その後で、remote Runtime、self-hosted Runtime、hosted cloud runtime fleet、runtime pool、resource allocation、quota、billing、sandbox、network policy、secret distribution を追加する。
```text
Phase 1: Web control plane + local Runtime
Phase 2: Remote/self-hosted Runtime
Phase 3: Hosted cloud runtime fleet
Phase 4: Resource allocation / scheduling / quotas / billing / isolation
```
@ -166,50 +197,52 @@ Desktop app は対応コストが高いので、まず Web frontend を primary
- Web: チームで使う主要 UI。
- CLI: automation、scripting、local operations。
- TUI/local panel: local runner cockpit、fallback、dogfooding surface。
- TUI/local panel: fallback、dogfooding surface。
- Future desktop: Web/control-plane model が安定した後に検討する optional client。
Web UI は Ticket、Objective、Memory、Knowledge、Run、Runner、Artifact を扱う。UI の都合で正本を二重化しない。
Web UI は Ticket、Objective、Memory、Knowledge、Runtime、Worker、Artifact を扱う。UI の都合で正本を二重化しない。
### 7. 多重起動コストと runtime placement を見直す
Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical agent session と runtime process/resource placement を分ける
Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical Worker session と Runtime process/resource placement を分ける。Runtime は Git Repository root や Workspace path に固定されず、request ごとに必要な Execution Workspace を materialize できる実行基盤として扱う
初期 Workspace DB では、Worker を canonical table として永続化しない。Host / Worker 一覧は backend-local runtime inspection や将来の Host protocol から逐次取得する live view とし、Ticket に関わった Worker は Ticket thread events と WorkerRef snapshot / TicketWorkerLink として記録する。
初期 Workspace DB では、Worker を canonical table として永続化しない。Runtime / Worker 一覧は backend-local runtime inspection や将来の Runtime protocol から逐次取得する live view とし、Ticket に関わった Worker は Ticket thread events と WorkerRef snapshot / TicketWorkerLink として記録する。
Worker の一元管理、データ永続化、アーカイブは将来的には必要になる。これは Host protocol、remote/self-hosted/hosted worker lifecycle、worker identity、retention policy、audit requirements が固まった後に、dedicated Worker registry / archive model として追加する。v0 で Pod metadata の代替として Worker table を作らない。
Worker の一元管理、データ永続化、アーカイブは将来的には必要になる。これは Runtime protocol、remote/self-hosted/hosted runtime lifecycle、worker identity、retention policy、audit requirements が固まった後に、dedicated Worker registry / archive model として追加する。v0 で Pod metadata の代替として Worker table を作らない。
検討対象:
- Agent identity と process/runtime placement の分離。
- 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 の責務。
- Provider client、tool registry、resource cache の共有可能性。
- Prompt/resource/profile resolution cache。
- Prompt/resource/profile/config bundle resolution cache。
- Model call multiplexing and scheduling。
- Tool execution sandbox reuse。
- Plugin instance / Service runtime との統合。
- Session/event stream と runtime lifecycle の分離。
- Runner-local cache、checkout reuse、build cache、dependency cache。
- Runtime-local cache、checkout reuse、build cache、dependency cache。
## Initial phases / candidate tickets
1. **Vocabulary / architecture record**
- Workspace / Repository / RepositoryPoint / Execution Workspace / Runner / Control Plane / Run / Ticket / Memory / Knowledge の用語と境界を固める。
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / Execution Workspace / Runtime / Worker / Control Plane / Ticket / Memory / Knowledge の用語と境界を固める。
2. **Team-space canonical data model**
- Ticket / Objective / Target / Run / Artifact / Actor / Permission / Audit / Memory / Knowledge の entity/event model を設計する。
3. **Ticket and Run separation**
- Ticket lifecycle と execution attempt / orchestration run / validation run を分離し、Ticket thread と Run evidence の責務を明確化する。
- Ticket / Objective / Target / Artifact / Actor / Permission / Audit / Memory / Knowledge の entity/event model を設計する。
3. **Ticket evidence model**
- Ticket lifecycle、WorkerRef、Artifact、validation evidence、review evidence、Ticket thread の責務を明確化する。
4. **Memory storage migration boundary**
- Memory / Knowledge の本格再設計は後回しにし、まずは Workspace backend に移す時の platform contract、compatibility/cache/export 方針、将来の provenance / visibility / approval 要件だけを固定する。
5. **Control plane backend architecture**
- local `.yoi` backend と server-side canonical backend の境界、migration/export/import、compatibility mode を設計する。
6. **Web control plane MVP design**
- read-only Ticket / Objective / Memory / Knowledge / Runner state UI/API の範囲を決める。
7. **Local runner protocol design**
- Web/control plane から local runner に安全な操作を送る protocol と authority boundary を設計する。
- 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**
- Repository URI、Repository provider capability、RepositoryPoint resolution、Git worktree / clone / sparse checkout / future source backend を runner-side strategy として抽象化する。
9. **Remote/hosted runner foundation**
- runner registration, heartbeat, capability advertisement, job assignment, logs/events, secrets, sandbox/resource policy を設計する。
- 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 を設計する。
## Non-goals
@ -218,32 +251,36 @@ Worker の一元管理、データ永続化、アーカイブは将来的には
- 最初から full hosted cloud execution を作ること。
- local execution / CLI / TUI / local panel を捨てること。
- Ticket を単なる issue tracker clone にすること。
- Memory を Ticket/Run audit log の代替にすること。
- Memory を Ticket/Artifact audit log の代替にすること。
- Web UI のために core authority を二重化すること。
- hidden server state を LLM context に直接注入すること。
- multi-tenant auth/billing/secret/security を shortcut して実装すること。
## Success criteria / exit conditions
- Workspace / Repository / RepositoryPoint / Execution Workspace / Runner / Control Plane / Run / Ticket / Memory / Knowledge の境界が文書化されている。
- Ticket が team coordination record として、target selector / Run / Artifact / Actor / Permission / Audit と分離された model を持つ。
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / Execution Workspace / 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 / Runner state を中心とした read-only view を提供できる設計または MVP を持つ。Memory / Knowledge は既存 record の表示または将来 placeholder に留め、本格再設計をこの段階の必須条件にしない。
- Control plane から local runner に対して、現在のローカル管理画面相当の安全な操作を実行できる design/protocol がある。
- Web UI/API が Ticket / Objective / Runtime / Worker state を中心とした read-only view を提供できる設計または MVP を持つ。Memory / Knowledge は既存 record の表示または将来 placeholder に留め、本格再設計をこの段階の必須条件にしない。
- Control plane から local Runtime に対して、現在のローカル管理画面相当の安全な操作を実行できる design/protocol がある。
- 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 への所属ではなく target selector / scope hint で対象を表現する。
- Git worktree 相当は Execution Workspace materialization strategy として扱われ、Run が immutable な RepositoryPoint を記録する。
- Memory / Knowledge は Ticket / Run / Artifact の authority を置き換えない record として platform contract だけを持つ。本格的な意味論・抽出・承認・検索・staleness 処理は、Memory の保存先を Workspace backend / control plane record に移すタイミングで回収する。
- Hosted runner / resource allocation / SaaS offering に進むための後続 Ticket が切れる状態になっている。
- Ticket と Objective は Workspace 配下に平たく存在し、Repository への所属ではなく RepositoryId / RepositorySelector / path scope / intent で対象を表現する。
- Git worktree 相当は Execution Workspace 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 が両立している。
## Decision context
- Yoi は hosted Git tool ではなく、team workspace control plane + execution environment として設計する。
- Yoi は hosted Git tool ではなく、team workspace control plane + Runtime execution environment として設計する。
- Team-space の長期 canonical authority は server-side control plane に置く。local `.yoi` は互換/local/offline/export/import surface だが、multi-user SaaS の正本ではない。
- 実行環境と管理システムは弱結合にする。まず管理システムを独立させ、local runner を実行環境として接続する。その後に remote/self-hosted/hosted runner fleet へ進む。
- 実行環境と管理システムは弱結合にする。まず管理システムを独立させ、local Runtime を実行環境として接続する。その後に remote/self-hosted/hosted runtime fleet へ進む。
- Runtime は Worker 群を束ねる実行基盤であり、将来的には作業環境の用意、sandbox、mount、checkout/worktree/container filesystem、cache、secret boundary を Worker launch request ごとに準備する。Runtime process は single Workspace / Git repository root 専用に固定しない。RepositorySelector は provider-specific な未解決 locator、RepositoryPoint は解決済み evidence として扱う。
- Web frontend を最初の primary team UI とする。Desktop app は web/control-plane model が安定した後に検討する。
- Git は重要な Repository provider / materialization backend として使うが、Workspace identity と authority を Git Repository root に固定しない。
- Ticket と Objective は Workspace 配下に平たく持つ。対象コードベースや ref は Repository target selector として表現し、Run が concrete RepositoryPoint に解決する。
- Memory の本格再設計は後回しにする。先に Workspace / Ticket / Repository / Host/Worker live view / Control plane の基盤を固め、Memory の保存先を Workspace backend に移すタイミングで、意味論・抽出・承認・検索・staleness 処理をまとめて回収する。
- Ticket と Objective は Workspace 配下に平たく持つ。対象コードベースや地点指定は RepositoryId / RepositorySelector / path scope / intent として表現し、Worker launch materialization が concrete RepositoryPoint に解決する。
- Backend Repository は cwd inspection ではなく、Workspace config の明示 Repository registry から構築する。`--workspace` は Repository ではなく workspace config root / local descriptor root を指す。
- `./.yoi` は local descriptor / fs-store / compatibility surface であり、将来の Backend canonical store と Workspace registry は `~/.yoi` 側へ寄せる。
- Memory の本格再設計は後回しにする。先に Workspace / Ticket / Repository / Runtime/Worker live view / Control plane の基盤を固め、Memory の保存先を Workspace backend に移すタイミングで、意味論・抽出・承認・検索・staleness 処理をまとめて回収する。
- Worker の一元管理・データ永続化・アーカイブも後続設計に回す。初期 DB では Worker を Pod metadata の代替として永続化せず、live view と Ticket-linked WorkerRef 記録に留める。

View File

@ -0,0 +1,35 @@
---
title: 'Live-reload Runtime connection registry changes'
state: 'done'
created_at: '2026-07-03T15:59:48Z'
updated_at: '2026-07-03T19:47:23Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-03T16:52:35Z'
---
## 背景
Workspace Settings の Runtime Connections v0 は remote Runtime の追加・削除を `.yoi/workspace-backend.local.toml` に永続化するが、実行中 Backend の `RuntimeRegistry` には反映しない。そのため、追加直後の connection test は通っても、worker launch 候補や実際の runtime routing に使うには Backend restart が必要になり、Settings 上に `runtime_registry_restart_required` warning が出る。
これは安全側の v0 制約として導入したものだが、Settings から接続を追加・テストしてすぐ使う UX では不自然で、接続テストの意味も分かりにくくしている。設定ファイルの desired state と active registry state を分ける方針は維持しつつ、Settings mutation 時に active registry を同期更新できる経路を作る。
## 要件
- remote Runtime add/delete 後、Backend restart なしで active `RuntimeRegistry` に反映される。
- add 成功後、その Runtime が worker 作成候補・worker list/detail/console/event routing など active registry 経由の Browser-facing API から利用可能になる。
- delete 成功後、その Runtime は active registry から除去され、新規 worker launch 候補や routing 対象から外れる。
- persisted config、active registry、connection test / negotiation result は引き続き別概念として扱う。
- Browser-facing API は endpoint/token/config path/socket/session/store path/live handle など authority-bearing internals を漏らさない。
- active worker や console subscription が存在する Runtime の削除時挙動を明示する。v1 では安全に拒否するか、既存 worker/session への影響がないことを保証した上で unregister する。
- registry apply 失敗時の config 永続化との整合性を明示する。config だけ保存されて active registry に反映されない中途半端な状態を、診断なしに成功扱いしない。
## 受け入れ条件
- Settings から remote Runtime を追加した直後に `restart_required` warning なしで利用可能になる。
- 追加直後の Runtime が manual Coding Worker launch の runtime candidate に現れる。
- Settings から remote Runtime を削除した直後に candidate / active registry から消える。
- 接続 test は live registry 反映可否と compatibility/negotiation 結果を区別して表示する。
- active worker が存在する Runtime delete の挙動について test がある。
- backend route tests と必要な web model/UI tests が追加される。
- `cargo test -p yoi-workspace-server``cd web/workspace && deno task test && deno task check` が通る。

View File

@ -0,0 +1,240 @@
<!-- event: create author: "yoi ticket" at: 2026-07-03T15:59:48Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-03T16:52:26Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-03T16:52:26Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-03T16:52:35Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-03T16:52:35Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-03T16:53:43Z -->
## Implementation report
実装報告。
- `RuntimeRegistry` を live mutation 可能な共有 registry にし、Settings の remote Runtime add/delete から `register_or_replace` / `unregister_if_idle` を呼ぶようにした。
- add は persisted config 書き込み前に remote runtime config を解決・構築し、成功後に active registry へ反映する。成功時は `restart_required = false``runtime_registry_applied` diagnostic を返す。
- delete は active registry 側に worker が残っている場合 `remote_runtime_delete_blocked` で拒否し、config は削除しない。worker が無ければ config と active registry の両方から削除する。
- Settings Test 表示は connected / verified / unchecked warning を分け、接続できた範囲が分かるようにした。
- `worker-runtime-rest-server` の default bind port を `127.0.0.1:38800` に固定し、default config コメントも同じ port に合わせた。
- remote runtime blocking HTTP client/request は async context 直下で Tokio runtime を drop しない boundary 経由にした。
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p yoi-workspace-server`
- `cargo test -p worker-runtime --features ws-server`
- `cd web/workspace && deno task check && deno task test`
Skipped:
- `nix build .#yoi --no-link` は重いため、ユーザー指示により未実行。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-03T16:53:43Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: implementation_report author: hare at: 2026-07-03T17:41:08Z -->
## Implementation report
Follow-up after manual Browser test.
Observed failure:
- Browser could connect/test `arcadia-test`, but worker creation failed with `workspace_worker_create_failed`: Runtime did not accept worker creation.
Root cause:
- The standalone `worker-runtime-rest-server` started from `worker-runtime` has no `WorkerExecutionBackend`; it can serve control/catalog endpoints but cannot spawn Workers.
- The connection test previously reported spawn as unknown, while runtime launch candidates projected remote worker creation as available.
Fix:
- Runtime summary now exposes `worker_creation_available` based on whether an execution backend is attached.
- Remote Runtime capability projection uses that flag for `can_spawn_worker` / `can_accept_input`.
- Settings connection test now marks `workers.spawn` incompatible with `remote_runtime_worker_creation_unavailable` when the Runtime has no execution backend.
- Added backend test covering a control-only Runtime with no execution backend.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p worker-runtime --features ws-server`
- `cargo test -p yoi-workspace-server`
- `cd web/workspace && deno task check && deno task test`
Skipped:
- `nix build .#yoi --no-link` per earlier user instruction to skip heavy build.
---
<!-- event: implementation_report author: hare at: 2026-07-03T17:53:10Z -->
## Implementation report
Follow-up after `can_spawn_worker=false` review.
User feedback:
- A registered Runtime that cannot spawn workers should not exist as a usable Runtime.
Fix:
- Removed the control-only `worker-runtime` package binary surface by disabling Cargo autobin discovery and deleting `crates/worker-runtime/src/main.rs`.
- Added `worker-runtime-rest-server` under the `worker` package, where it can attach `WorkerRuntimeExecutionBackend` to the Runtime before serving REST/WebSocket endpoints.
- The server binary now starts a worker-backed Runtime, so `/v1/runtime` reports `worker_creation_available=true` for the intended standalone process.
- Retained fail-closed capability projection for old/control-only Runtime summaries so a process without execution backend is rejected as incompatible rather than exposed as a usable candidate.
New local command:
- `cargo run -p worker --features runtime-adapter --bin worker-runtime-rest-server -- --workspace .`
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -p yoi`
- `cargo test -p worker-runtime --features ws-server`
- `cargo test -p worker --features runtime-adapter --bin worker-runtime-rest-server`
- `cargo test -p yoi-workspace-server`
- `cd web/workspace && deno task check && deno task test`
- Smoke: started worker-backed Runtime on `127.0.0.1:38801` and verified `/v1/runtime` contains `"worker_creation_available":true`.
Skipped:
- `nix build .#yoi --no-link` per earlier user instruction to skip heavy build.
---
<!-- event: implementation_report author: hare at: 2026-07-03T18:33:15Z -->
## Implementation report
Follow-up after dependency-boundary review.
User feedback:
- Putting the standalone Runtime server binary in `worker` was also wrong; `worker` should not depend on Runtime just to host the adapter.
Correction:
- Split the Runtime/Worker bridge into a dedicated `worker-runtime-adapter` crate.
- Moved `WorkerRuntimeExecutionBackend` / `ProfileRuntimeWorkerFactory` out of `worker` and into `worker-runtime-adapter`.
- Moved the `worker-runtime-rest-server` binary into `worker-runtime-adapter`.
- Removed the `worker` -> `worker-runtime` dependency and removed the `runtime-adapter` feature from `worker`.
- `worker-runtime` remains the Runtime domain/API crate; `worker` remains Worker implementation; `worker-runtime-adapter` is the only crate depending on both.
New local command:
- `cargo run -p worker-runtime-adapter --bin worker-runtime-rest-server -- --workspace .`
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -p worker`
- `cargo check -p yoi`
- `cargo test -p worker-runtime --features ws-server`
- `cargo test -p worker-runtime-adapter`
- `cargo test -p yoi-workspace-server`
- `cd web/workspace && deno task check && deno task test`
Skipped:
- `nix build .#yoi --no-link` per earlier user instruction to skip heavy build.
---
<!-- event: implementation_report author: hare at: 2026-07-03T19:01:34Z -->
## Implementation report
Correction after dependency-boundary review.
User feedback:
- A separate bridge crate is unnecessary; the Runtime should bundle/manage Workers directly.
Correction:
- Removed `worker-runtime-adapter` crate entirely.
- Moved `WorkerRuntimeExecutionBackend` / `ProfileRuntimeWorkerFactory` into `worker-runtime::worker_backend`.
- Moved the `worker-runtime-rest-server` binary back into `worker-runtime`, but kept it worker-backed rather than the old control-only process.
- `worker` no longer depends on `worker-runtime`; `worker-runtime` now depends on `worker` and owns the Runtime process that bundles Workers.
- `workspace-server` now imports `worker_runtime::worker_backend::WorkerRuntimeExecutionBackend`.
Correct local command:
- `cargo run -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --workspace .`
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo check -p worker`
- `cargo check -p yoi`
- `cargo check -p worker-runtime --features ws-server,fs-store`
- `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`
Skipped:
- `nix build .#yoi --no-link` per earlier user instruction to skip heavy build.
---
<!-- event: implementation_report author: hare at: 2026-07-03T19:47:23Z -->
## Implementation report
Follow-up after Runtime generation path review.
User feedback:
- Runtime generation paths should not be duplicated; a Runtime that can be served over HTTP must be constructed explicitly with the intended Worker backend rather than implicitly from HTTP server config.
Fix:
- Removed `RuntimeHttpServer::bind`.
- Removed `serve_configured_runtime_http`.
- Removed the internal `runtime_from_config` path that built backend-less Runtime instances from `RuntimeHttpServerConfig`.
- `http_server` now serves only an already-constructed `Runtime` via `serve_runtime_http(runtime, listener, token)`.
Validation:
- `cargo fmt --check`
- `git diff --check`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server`
Skipped:
- `nix build .#yoi --no-link` per earlier user instruction to skip heavy build.
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260704-193545-1","ticket_id":"00001KWPC13WQ","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWPC13WQ は独立 queued Ticket で blocker なし。Workspace Repository projection を config-driven registry に移行する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWPC13WQ-repository-registry","worktree":"/home/hare/Projects/yoi/.worktree/00001KWPC13WQ-repository-registry","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Repository registry config/API/backend projection と web compatibility を実装する。完了後、別 sibling Reviewer Pod で Ticket の IntentPacket / invariants / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-04T19:35:45Z"}

View File

@ -0,0 +1,49 @@
---
title: 'Config-driven Repository registry for Workspace Backend'
state: 'closed'
created_at: '2026-07-04T10:50:45Z'
updated_at: '2026-07-04T20:19:15Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-07-04T19:34:04Z'
---
## 背景
現在の Workspace Backend の Repository view は、`--workspace .` / cwd / workspace root を暗黙に local Git repository として inspect して表示する薄い read-only view になっている。これは短期 UI には便利だったが、Objective の方向性とは合わない。
今後は Workspace を Git repository root と同一視せず、Backend は明示設定された Repository registry を持つ必要がある。`./.yoi` は当面 backend fs-store / local descriptor として残すが、将来的には `~/.yoi/` 側に Backend store を置き、1 Backend が複数 Workspace を扱える設計へ進む。そのため `--workspace .` は workspace config root の指定に縮退させ、cwd を自動的に Repository として扱う経路は廃止する。
`./` を Repository として扱いたい場合も、`.yoi/workspace-backend.local.toml` に明示的な Repository entry として指定する。
## 要件
- `WorkspaceBackendConfigFile` / `.yoi/workspace-backend.local.toml` に明示的な Repository registry を追加する。
- `[[repositories]]` entry は少なくとも `id`, `provider`, `uri`, optional `display_name`, optional `default_selector` を持てる。
- `uri = "."` は backend process cwd ではなく、workspace config root からの相対 URI/path として解釈する。
- `--workspace` は Repository ではなく workspace config root / local descriptor root として扱う。
- cwd / workspace root を暗黙 Repository として Browser/API に返す fallback を廃止する。
- `/api/repositories` は configured repositories だけを返す。
- Repository 未設定時は empty list と typed diagnostic を返し、暗黙の cwd Repository を作らない。
- v0 provider は `git` を主対象にし、必要なら `local_fs` は placeholder/diagnostic 程度に留める。
- `RepositoryId` / `RepositorySelector` / `RepositoryPoint` の用語と将来拡張を壊さない型境界にする。Selector は provider-specific な未解決 locator、Point は解決済み evidence であり、Git branch/tag/hash 固定の抽象化にしない。
- Browser-facing response は token/auth ref/secret、不要な absolute host path、internal config/store/runtime paths を漏らさない。
## 受け入れ条件
- `.yoi/workspace-backend.local.toml``[[repositories]]` を書ける。
- dogfood workspace config に `id = "main"`, `provider = "git"`, `uri = "."` 相当の Repository が明示されている。
- `/api/repositories` は configured Repository のみを返し、`--workspace` / cwd を自動 Repository として返さない。
- Repository 未設定 workspace では `/api/repositories` が empty list + `repository_config_empty` 相当の warning diagnostic を返す。
- configured Git Repository については branch/head/status/log など既存 UI が必要とする summary を取得できる。
- `/api/repositories/{id}/log` は configured Git Repository id のみ受け付け、未知 id / unsupported provider には typed sanitized error を返す。
- 既存 web UI は Repository 未設定時にも壊れず、明示 Repository がある場合だけ repository view を表示できる。
- `cargo test -p yoi-workspace-server` が通る。
- `cd web/workspace && deno task check && deno task test` が通る。
## 非目標
- Runtime materialization / Execution Workspace 作成をこのチケットで実装すること。
- multi-workspace Backend store を `~/.yoi/` に完全移行すること。
- Repository credential / clone / fetch / write operation を実装すること。
- Ticket target から RepositoryPoint を解決して Worker launch に渡すこと。

View File

@ -0,0 +1,31 @@
完了。
実装内容:
- Workspace Backend に explicit `[[repositories]]` registry を追加した。
- `.yoi/workspace-backend.local.toml` / `resources/workspace-backend.default.toml` に Repository config schema を導入し、dogfood workspace config に explicit `main` Git repository entry (`uri = "."`) を追加した。
- `uri = "."` は workspace config root 相対として解釈し、process cwd / workspace root を暗黙 Repository として扱う fallback を廃止した。
- `/api/repositories` は configured repositories のみを返し、未設定時は empty list + `repository_config_empty` warning diagnostic を返す。
- `/api/repositories/{id}/log` は configured Git repository id のみを受け付け、未知 id / unsupported provider は typed sanitized diagnostics で fail closed するようにした。
- Browser-facing repository summary は configured `uri` / resolved local path / secret / internal path を露出せず、Git remote も credentials / local absolute path / `file://...` を redaction するようにした。
- Web UI は configured repositories から navigation を導出し、empty registry で hardcoded `main` repository を invent しないようにした。
- Focused backend/web tests を追加した。
主な commit / merge:
- implementation: `2f0d1cee feat: add workspace repository registry`
- review fix: `14e63ca5 fix: derive repository UI from registry`
- merge into orchestration: `a786fd85 merge: repository registry implementation`
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`: 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 変更ではないため未実行。

View File

@ -0,0 +1,339 @@
<!-- event: create author: "yoi ticket" at: 2026-07-04T10:50:45Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-04T10:59:11Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-04T10:59:11Z 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-04T19:34:04Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-04T19:35:39Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket は Workspace Backend の Repository registry を config-driven にする範囲、非目標、受け入れ条件が具体化されている。
- `WorkspaceBackendConfigFile` / `.yoi/workspace-backend.local.toml``[[repositories]]` を追加し、Browser/API が configured repositories のみを返すという binding decision が明確。
- `RepositoryId` / `RepositorySelector` / `RepositoryPoint` の用語境界と、secret/path leak を避ける Browser-facing invariant が明記されている。
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
Evidence checked:
- Ticket body / thread / artifacts。
- `TicketRelationQuery(00001KWPC13WQ)`: 0 件。
- `TicketOrchestrationPlanQuery(00001KWPC13WQ)`: 0 件。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket 一覧: この Ticket 1件のみ。ready / inprogress は 0 件。
- visible Pods: 2 visible pods既存 visible state の確認のみ、spawn は未実施)。
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
- Bounded code map: `crates/workspace-server/src/config.rs`, `crates/workspace-server/src/repositories.rs`, `crates/workspace-server/src/server.rs`, `crates/workspace-server/src/store.rs`, repository-related web workspace files。
IntentPacket:
Intent:
- Workspace Backend の Repository view を workspace root/cwd の暗黙 fallback から、`.yoi/workspace-backend.local.toml` の explicit Repository registry に移行する。
- `/api/repositories``/api/repositories/{id}/log` は configured repositories のみを扱い、未設定時は empty list + typed warning diagnostic を返す。
Binding decisions / invariants:
- `--workspace` は Repository root ではなく workspace config root / local descriptor root として扱う。
- `uri = "."` は backend process cwd ではなく workspace config root 相対として解釈する。
- cwd / workspace root を暗黙 Repository として Browser/API に返す fallback は廃止する。
- dogfood workspace config には `id = "main"`, `provider = "git"`, `uri = "."` 相当の explicit Repository entry を追加する。
- v0 provider は `git` 主対象。`local_fs` は必要なら placeholder/diagnostic 程度。
- `RepositorySelector` は provider-specific な未解決 locator、`RepositoryPoint` は解決済み evidence として、Git branch/tag/hash 固定抽象にしない。
- Browser-facing response は token/auth ref/secret、不要な absolute host path、internal config/store/runtime paths を漏らさない。
Requirements / acceptance criteria:
- `.yoi/workspace-backend.local.toml``[[repositories]]` を書ける。
- `/api/repositories` は configured repositories のみを返す。
- Repository 未設定 workspace は empty list + `repository_config_empty` 相当 warning diagnostic。
- configured Git Repository では branch/head/status/log など既存 UI が必要とする summary を取得できる。
- `/api/repositories/{id}/log` は configured Git Repository id のみ受け付け、未知 id / unsupported provider は typed sanitized error。
- 既存 web UI は未設定時にも壊れず、明示 Repository がある場合だけ repository view を表示できる。
Implementation latitude:
- 型名、module分割、API response shape の細部は既存 `workspace-server` / web workspace style に合わせてよい。
- config TOML の comments/format preservation は既存 local config serializer の範囲で扱う。
- Browser-facing path 表示は safe display name / sanitized summary に寄せ、必要なら absolute path を API 内部で使っても response に不要露出しない。
- 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 解決が必要になる場合。
- Browser-facing API に secret/auth ref/internal absolute paths を出さないと実装できない場合。
- `RepositorySelector` / `RepositoryPoint` の概念境界を設計変更する必要が出た場合。
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` は Cargo.lock / dependency / resource packaging / Nix 変更がある場合だけ実行し、未実行なら理由を報告する。
Current code map:
- Config: `crates/workspace-server/src/config.rs` (`WorkspaceBackendConfigFile`, local config load/write/resolve)。
- Repository projection: `crates/workspace-server/src/repositories.rs` (`LocalRepositoryReader`, `RepositorySummary`, git inspect/log)。
- API wiring: `crates/workspace-server/src/server.rs` (`/api/repositories`, `/api/repositories/{repository_id}`, log route)。
- Store/schema context: `crates/workspace-server/src/store.rs` repositories table migration context。
- Web: workspace repository panels/routes using `/api/repositories` and repository log summaries.
Critical risks / reviewer focus:
- No implicit workspace-root/cwd repository fallback remains in Browser/API output.
- configured repository ids, unknown ids, and unsupported providers fail closed with typed sanitized diagnostics.
- path/secret/internal config/store/runtime details do not leak to Browser-facing responses.
- `uri = "."` is resolved against workspace config root, not process cwd.
- dogfood `.yoi/workspace-backend.local.toml` contains explicit `main` repository entry without introducing secret/local-only fields into tracked records.
---
<!-- event: state_changed author: orchestrator at: 2026-07-04T19:35:52Z 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(00001KWPC13WQ)`: blocking relation 0 件。
- `TicketOrchestrationPlanQuery(00001KWPC13WQ)`: prior record 0 件。今回 accepted_plan を記録済み。
- Orchestrator worktree git status: clean on `orchestration`
- queued Ticket はこの Ticket 1件のみ。ready / inprogress は 0 件。
- visible Pods / TicketDoctor / bounded code map を確認。
Acceptance basis:
- concrete missing decision / information は残っていない。
- Ticket の scope は explicit Repository registry / API projection / web compatibility に閉じており、非目標も明確。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-04T19:36:56Z -->
## Implementation report
Implementation routing update:
- Worktree-workflow に従い implementation worktree を作成した。
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWPC13WQ-repository-registry`
- Branch: `work/00001KWPC13WQ-repository-registry`
- Worktree には tracked `.yoi` project records が見えている。
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
- Sibling Coder Pod `yoi-coder-00001KWPC13WQ-repository-registry` を起動した。
- 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-04T19:58:53Z -->
## Implementation report
Implementation progress report:
- Coder Pod completed implementation and committed `2f0d1cee feat: add workspace repository registry` on branch `work/00001KWPC13WQ-repository-registry`.
- Worktree status was clean after commit.
- Changed files include:
- `.yoi/workspace-backend.local.toml`
- `resources/workspace-backend.default.toml`
- `crates/workspace-server/src/config.rs`
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/src/repositories.rs`
- `crates/workspace-server/src/server.rs`
- web workspace repository/sidebar route files.
- Implementation summary: explicit `[[repositories]]` registry, config-root-relative `uri`, no implicit cwd/workspace-root repository fallback, empty registry diagnostic, configured-id-only log route, typed sanitized unknown/unsupported diagnostics, dogfood `main` repository entry.
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `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`: pass13 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-04T20:02:57Z status: request_changes -->
## Review: request changes
External review result: request_changes
Blockers:
1. Web UI still exposes an implicit hardcoded `main` repository instead of deriving repository navigation from configured repositories.
- `web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte:12-25` always renders count `1` and a `/repositories/main` link labeled “configured repository”。
- `WorkspacePage.svelte:25-29` still defaults `repositoryId = 'main'`, and `WorkspacePage.svelte:181-187` loads repository data/tickets even outside the repository route。
- This violates the acceptance criterion that the web UI behave safely with no configured repositories and display repository views only for explicit repositories. In an empty registry workspace, the sidebar still advertises `main`
2. Browser-facing repository summaries can leak local absolute host paths through Git remotes.
- `crates/workspace-server/src/repositories.rs:223-225` reads `git remote -v`
- `parse_remotes` exposes `fetch_url` in `GitRemoteSummary` (`repositories.rs:42-46`, `274-291`)。
- `sanitize_remote_url` only redacts `scheme://credentials@host` userinfo (`repositories.rs:338-345`); local absolute remotes such as `/home/.../repo.git` or `file:///home/...` are returned unchanged。
- This conflicts with the Ticket invariant that browser-facing responses must not leak unnecessary absolute host paths/internal path details。
Validation / evidence reviewed:
- Ticket `item.md`, routing decision / IntentPacket, and orchestration plan。
- Commit `2f0d1cee` diff/stat/name list and relevant backend/web files。
- `git diff --check 2f0d1cee^ 2f0d1cee` had no whitespace errors。
- Positive backend pieces exist: explicit dogfood `main` config, config-level `[[repositories]]`, `uri = "."` workspace-root resolution test, empty-registry `repository_config_empty` diagnostic, and unknown/unsupported repository log route tests。
Non-blocking follow-ups:
- Add web-side tests or component-model coverage for empty repository registry / non-`main` configured repository navigation so the hardcoded fallback cannot regress。
- Consider a stricter public `GitRemoteSummary` shape that reports only sanitized display metadata, not raw fetch URLs。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-04T20:11:32Z -->
## Implementation report
Review-fix implementation report:
- Coder Pod completed requested changes and committed follow-up `14e63ca5 fix: derive repository UI from registry` on branch `work/00001KWPC13WQ-repository-registry`.
- Worktree status was clean after commit.
- Fix summary:
- Web UI no longer hardcodes implicit `main` repository navigation/default route behavior.
- Repository navigation is derived from configured repositories; empty registry does not invent `main` and can surface diagnostics such as `repository_config_empty`.
- Repository details/tickets load only for explicit repository routes.
- Browser-facing Git remote display values now redact local absolute remotes such as `/home/...`, `/Users/...`, Windows-style absolute paths, `file:///...`, and `file://localhost/...`, while preserving credential redaction.
- Added focused web tests for empty registry and non-`main` configured repository navigation.
- Orchestrator reran validation in the implementation worktree:
- `git diff --check`: pass
- `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 変更ではないため未実行。
- Requesting follow-up external review against `2f0d1cee..14e63ca5` and full combined implementation。
---
<!-- event: review author: reviewer at: 2026-07-04T20:17:56Z status: approve -->
## Review: approve
External follow-up review result: approve
Blockers: none.
Evidence reviewed in implementation worktree only:
- Ticket `00001KWPC13WQ` item/thread and IntentPacket: explicit config-driven registry, no cwd/workspace-root fallback, `uri = "."` relative to workspace config root, browser-facing path/secret invariant.
- `git show --stat --check 14e63ca5`, `git diff --stat 2f0d1cee..14e63ca5`, combined changed-file set, and relevant full files.
Findings:
- Prior blocker 1 fixed: `/api/repositories` is built from `config.repositories` only, empty registry returns empty items plus `repository_config_empty`, sidebar derives links via `projectRepositoryNav`, no hardcoded `/repositories/main` remains, `WorkspacePage` no longer defaults to `main`, repository fetches happen only for explicit route ids, and non-`main` ids are covered by tests。
- Prior blocker 2 fixed: Browser-facing `RepositorySummary` does not serialize configured `uri` or resolved local `path`; Git command failure diagnostics are generic; remote URLs redact credentials and local absolute/file remotes including `/home/...`, `/Users/...`, Windows absolute paths, `file:///...`, and `file://localhost/...`, with tests。
- `uri = "."` resolves against workspace/config root, not process cwd, with unit test。
- Unknown repository ids map to typed `repository_not_configured` diagnostics and 404。
- Unsupported providers for git log map to typed `repository_provider_unsupported` diagnostics and 400。
- Dogfood `.yoi/workspace-backend.local.toml` contains explicit `main` git repository entry with `uri = "."` and no secret/local-only fields。
- No implicit workspace-root/cwd repository fallback found in Browser/API output paths。
Validation note:
- Reviewer did not rerun the full test suite; reviewer relied on Orchestrator-reported passing validation and performed read-only diff/file inspection。
- `git show --check` had no whitespace errors for the fix commit。
No merge-blocking issues found.
---
<!-- event: state_changed author: orchestrator at: 2026-07-04T20:19:02Z 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/00001KWPC13WQ-repository-registry` was merged into the orchestration branch with merge commit `a786fd85 merge: repository registry implementation`
- Final validation in Orchestrator worktree passed:
- `git diff --check`: pass
- `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 変更ではないため未実行。
Implementation commits:
- `2f0d1cee feat: add workspace repository registry`
- `14e63ca5 fix: derive repository UI from registry`
No merge-blocking issues remain.
---
<!-- event: state_changed author: hare at: 2026-07-04T20:19:15Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-04T20:19:15Z status: closed -->
## 完了
完了。
実装内容:
- Workspace Backend に explicit `[[repositories]]` registry を追加した。
- `.yoi/workspace-backend.local.toml` / `resources/workspace-backend.default.toml` に Repository config schema を導入し、dogfood workspace config に explicit `main` Git repository entry (`uri = "."`) を追加した。
- `uri = "."` は workspace config root 相対として解釈し、process cwd / workspace root を暗黙 Repository として扱う fallback を廃止した。
- `/api/repositories` は configured repositories のみを返し、未設定時は empty list + `repository_config_empty` warning diagnostic を返す。
- `/api/repositories/{id}/log` は configured Git repository id のみを受け付け、未知 id / unsupported provider は typed sanitized diagnostics で fail closed するようにした。
- Browser-facing repository summary は configured `uri` / resolved local path / secret / internal path を露出せず、Git remote も credentials / local absolute path / `file://...` を redaction するようにした。
- Web UI は configured repositories から navigation を導出し、empty registry で hardcoded `main` repository を invent しないようにした。
- Focused backend/web tests を追加した。
主な commit / merge:
- implementation: `2f0d1cee feat: add workspace repository registry`
- review fix: `14e63ca5 fix: derive repository UI from registry`
- merge into orchestration: `a786fd85 merge: repository registry implementation`
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`: 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 変更ではないため未実行。
---

View File

@ -0,0 +1,9 @@
# Dogfood Workspace Backend configuration.
# Repository URI values are resolved from this workspace config root.
[[repositories]]
id = "main"
provider = "git"
uri = "."
display_name = "Yoi"
default_selector = "HEAD"

7
Cargo.lock generated
View File

@ -5904,7 +5904,6 @@ dependencies = [
"uuid",
"wasmtime",
"wat",
"worker-runtime",
"workflow",
"yoi-plugin-pdk",
]
@ -5913,16 +5912,22 @@ dependencies = [
name = "worker-runtime"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"futures",
"llm-engine",
"manifest",
"protocol",
"serde",
"serde_json",
"session-store",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.29.0",
"tower",
"worker",
]
[[package]]

View File

@ -4,29 +4,37 @@ description = "Embedded memory-backed Runtime API for Worker management"
version = "0.1.0"
edition.workspace = true
license.workspace = true
autobins = false
[[bin]]
name = "worker-runtime-rest-server"
path = "src/main.rs"
required-features = ["http-server"]
required-features = ["ws-server", "fs-store"]
[features]
default = []
fs-store = ["dep:serde_json"]
http-server = ["dep:axum", "dep:serde_json", "dep:tokio", "dep:tower"]
ws-server = ["http-server", "axum/ws", "dep:futures", "dep:protocol", "tokio/sync"]
http-server = ["dep:axum", "dep:serde_json", "dep:tower"]
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 }
protocol = { workspace = true, optional = 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 }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt"], optional = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
tower = { workspace = true, features = ["util"], optional = true }
worker.workspace = true
[dev-dependencies]
futures.workspace = true
llm-engine.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tokio-tungstenite.workspace = true

View File

@ -0,0 +1,33 @@
# worker-runtime
`worker-runtime` owns the Runtime authority surface for Worker management. A Runtime process bundles Worker lifecycle management, the HTTP/WebSocket control API, and the Worker execution backend.
## Run the local Runtime server
From the repository root:
```bash
cargo run -p worker-runtime \
--features ws-server,fs-store \
--bin worker-runtime-rest-server \
-- --workspace .
```
By default the server listens on:
```text
127.0.0.1:38800
```
To bind another address explicitly:
```bash
cargo run -p worker-runtime \
--features ws-server,fs-store \
--bin worker-runtime-rest-server \
-- --workspace . --bind 127.0.0.1:38800
```
`--workspace` is currently a legacy bootstrap input for the v0 local materializer / Worker profile resolution path. It is not intended to be the long-term Runtime identity or a single-workspace binding. Future Runtime launches should receive Workspace / Repository context through Worker launch requests and config bundles instead.
The REST server is intended for a trusted Backend/proxy, not direct browser access.

View File

@ -12,11 +12,9 @@ use crate::catalog::{
};
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use crate::error::RuntimeError;
#[cfg(feature = "fs-store")]
use crate::fs_store::FsRuntimeStoreOptions;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use crate::interaction::{WorkerInput, WorkerInteractionAck};
use crate::management::{RuntimeLimits, RuntimeOptions, RuntimeSummary};
use crate::management::{RuntimeLimits, RuntimeSummary};
#[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationCursor;
use crate::observation::{TranscriptProjection, TranscriptQuery};
@ -40,6 +38,12 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::TcpListener;
const DEFAULT_RUNTIME_HTTP_PORT: u16 = 38800;
fn default_runtime_http_bind_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], DEFAULT_RUNTIME_HTTP_PORT))
}
/// v0 Runtime REST server configuration.
#[derive(Clone, PartialEq, Eq)]
pub struct RuntimeHttpServerConfig {
@ -63,7 +67,7 @@ pub struct RuntimeHttpServerConfig {
impl Default for RuntimeHttpServerConfig {
fn default() -> Self {
Self {
bind_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
bind_addr: default_runtime_http_bind_addr(),
runtime_id: None,
display_name: None,
limits: RuntimeLimits::default(),
@ -102,48 +106,6 @@ pub enum RuntimeHttpStoreSelection {
},
}
/// Bound REST server instance.
pub struct RuntimeHttpServer {
runtime: Runtime,
local_token: Option<String>,
listener: TcpListener,
}
impl RuntimeHttpServer {
/// Build a Runtime from config and bind the configured address.
pub async fn bind(config: RuntimeHttpServerConfig) -> Result<Self, RuntimeHttpServerError> {
let runtime = runtime_from_config(&config)?;
let listener = TcpListener::bind(config.bind_addr).await?;
Ok(Self {
runtime,
local_token: config.local_token,
listener,
})
}
/// Address actually bound by the server.
pub fn local_addr(&self) -> Result<SocketAddr, RuntimeHttpServerError> {
Ok(self.listener.local_addr()?)
}
/// Runtime owned by this server.
pub fn runtime(&self) -> Runtime {
self.runtime.clone()
}
/// Serve requests until the axum server is stopped or returns an error.
pub async fn serve(self) -> Result<(), RuntimeHttpServerError> {
serve_runtime_http(self.runtime, self.listener, self.local_token).await
}
}
/// Convenience entry point: bind and serve a configured Runtime REST process API.
pub async fn serve_configured_runtime_http(
config: RuntimeHttpServerConfig,
) -> Result<(), RuntimeHttpServerError> {
RuntimeHttpServer::bind(config).await?.serve().await
}
/// Serve an existing Runtime on a pre-bound listener.
pub async fn serve_runtime_http(
runtime: Runtime,
@ -194,27 +156,6 @@ pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Rou
.layer(middleware::from_fn_with_state(state, require_local_token))
}
fn runtime_from_config(
config: &RuntimeHttpServerConfig,
) -> Result<Runtime, RuntimeHttpServerError> {
match &config.store {
RuntimeHttpStoreSelection::Memory => Ok(Runtime::with_options(RuntimeOptions {
runtime_id: config.runtime_id.clone(),
display_name: config.display_name.clone(),
limits: config.limits.clone(),
})),
#[cfg(feature = "fs-store")]
RuntimeHttpStoreSelection::Fs { root } => {
Ok(Runtime::with_fs_store(FsRuntimeStoreOptions {
root: root.clone(),
runtime_id: config.runtime_id.clone(),
display_name: config.display_name.clone(),
limits: config.limits.clone(),
})?)
}
}
}
#[derive(Clone)]
struct RuntimeHttpState {
runtime: Runtime,
@ -1134,7 +1075,7 @@ mod ws_tests {
WorkerExecutionResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult,
};
use crate::interaction::WorkerInput;
use crate::management::RuntimeOptions;
use futures::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio_tungstenite::connect_async;

View File

@ -1,11 +1,10 @@
//! Embedded Runtime domain API for Worker management.
//!
//! `worker-runtime` keeps its core independent from HTTP/WebSocket servers,
//! provider execution, and the existing Worker host. Filesystem persistence is
//! available only through the optional `fs-store` feature, and the minimal REST
//! process adapter is available only through the optional `http-server` feature.
//! The crate defines the in-process Runtime authority surface that higher layers
//! can later adapt into registries or backend APIs.
//! `worker-runtime` owns the Runtime authority surface and, for the standalone
//! process, wires that Runtime to the real Worker host. Filesystem persistence is
//! available only through the optional `fs-store` feature, and the REST/WebSocket
//! server is available only through the optional `http-server` / `ws-server`
//! features.
pub mod catalog;
pub mod config_bundle;
@ -21,6 +20,7 @@ pub mod interaction;
pub mod management;
pub mod observation;
mod runtime;
pub mod worker_backend;
#[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};

View File

@ -1,8 +1,8 @@
//! Minimal Runtime REST process wrapper.
//! Worker-backed Runtime REST process wrapper.
//!
//! This binary is available only when the `http-server` feature is enabled. It
//! starts a Runtime-local command API intended for a trusted backend/proxy;
//! browsers must not connect to this Runtime process directly.
//! This binary starts a Runtime command API with a real worker execution backend.
//! A REST Runtime process that cannot spawn Workers is not a valid Runtime for the
//! Workspace Browser.
use std::collections::VecDeque;
use std::env;
@ -11,11 +11,16 @@ use std::fmt;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use worker_runtime::error::RuntimeError;
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpServer, RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
};
use worker_runtime::identity::RuntimeId;
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode {
match run() {
@ -40,25 +45,77 @@ fn run() -> Result<(), ProcessError> {
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_all()
.build()?;
runtime.block_on(async move {
let server = RuntimeHttpServer::bind(config).await?;
let local_addr = server.local_addr()?;
let listener = tokio::net::TcpListener::bind(config.http.bind_addr).await?;
let local_addr = listener.local_addr()?;
let worker_runtime = build_runtime(&config)?;
eprintln!(
"worker-runtime REST server listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
);
server.serve().await
worker_runtime::http_server::serve_runtime_http(
worker_runtime,
listener,
config.http.local_token,
)
.await
.map_err(ProcessError::from)
})?;
Ok(())
}
fn parse_args<I, S>(args: I) -> Result<Option<RuntimeHttpServerConfig>, ProcessError>
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
let mut factory = ProfileRuntimeWorkerFactory::new(config.workspace_root.clone())
.with_cwd(config.cwd.clone());
if let Some(store_dir) = config.worker_store_dir.clone() {
factory = factory.with_store_dir(store_dir);
}
if let Some(metadata_dir) = config.worker_metadata_dir.clone() {
factory = factory.with_worker_metadata_dir(metadata_dir);
}
if let Some(runtime_base_dir) = config.worker_runtime_base_dir.clone() {
factory = factory.with_runtime_base_dir(runtime_base_dir);
}
if let Some(profile) = config.profile.clone() {
factory = factory.with_profile(profile);
}
let backend =
Arc::new(WorkerRuntimeExecutionBackend::new(factory).map_err(ProcessError::WorkerAdapter)?);
match &config.http.store {
RuntimeHttpStoreSelection::Memory => {
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
.map_err(ProcessError::Runtime)
}
RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone());
options.runtime_id = config.http.runtime_id.clone();
options.display_name = config.http.display_name.clone();
options.limits = config.http.limits.clone();
Runtime::with_fs_store_and_execution_backend(options, backend)
.map_err(ProcessError::Runtime)
}
_ => Err(ProcessError::usage(
"unsupported Runtime catalog store selection".to_string(),
)),
}
}
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
RuntimeOptions {
runtime_id: config.runtime_id.clone(),
display_name: config.display_name.clone(),
limits: config.limits.clone(),
}
}
fn parse_args<I, S>(args: I) -> Result<Option<ProcessConfig>, ProcessError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut config = RuntimeHttpServerConfig::default();
let mut config = ProcessConfig::default()?;
let mut store = StoreArg::Memory;
let mut args = args.into_iter().map(Into::into).collect::<VecDeque<_>>();
@ -68,18 +125,39 @@ where
"--help" | "-h" => return Ok(None),
"--bind" => {
let value = take_value(&flag, inline_value, &mut args)?;
config.bind_addr = value.parse::<SocketAddr>().map_err(|error| {
config.http.bind_addr = value.parse::<SocketAddr>().map_err(|error| {
ProcessError::usage(format!("invalid --bind socket address `{value}`: {error}"))
})?;
}
"--runtime-id" => {
let value = take_value(&flag, inline_value, &mut args)?;
config.runtime_id = Some(RuntimeId::new(value).ok_or_else(|| {
config.http.runtime_id = Some(RuntimeId::new(value).ok_or_else(|| {
ProcessError::usage("--runtime-id must not be empty".to_string())
})?);
}
"--display-name" => {
config.display_name = Some(take_value(&flag, inline_value, &mut args)?);
config.http.display_name = Some(take_value(&flag, inline_value, &mut args)?);
}
"--workspace" => {
config.workspace_root = PathBuf::from(take_value(&flag, inline_value, &mut args)?);
}
"--cwd" => {
config.cwd = PathBuf::from(take_value(&flag, inline_value, &mut args)?);
}
"--worker-store-dir" => {
config.worker_store_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--worker-metadata-dir" => {
config.worker_metadata_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--worker-runtime-base-dir" => {
config.worker_runtime_base_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--profile" => {
config.profile = Some(take_value(&flag, inline_value, &mut args)?);
}
"--store" => {
let value = take_value(&flag, inline_value, &mut args)?;
@ -106,7 +184,7 @@ where
"--local-token must not be empty when provided".to_string(),
));
}
config.local_token = Some(value);
config.http.local_token = Some(value);
}
"--local-token-env" => {
let name = take_value(&flag, inline_value, &mut args)?;
@ -120,14 +198,14 @@ where
"--local-token-env `{name}` resolved to an empty value"
)));
}
config.local_token = Some(value);
config.http.local_token = Some(value);
}
"--max-transcript-projection-items" => {
config.limits.max_transcript_projection_items =
config.http.limits.max_transcript_projection_items =
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
}
"--max-event-batch-items" => {
config.limits.max_event_batch_items =
config.http.limits.max_event_batch_items =
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
}
_ => {
@ -136,7 +214,10 @@ where
}
}
apply_store_selection(&mut config, store)?;
apply_store_selection(&mut config.http, store)?;
if config.cwd.as_os_str().is_empty() {
config.cwd = config.workspace_root.clone();
}
Ok(Some(config))
}
@ -180,31 +261,40 @@ fn apply_store_selection(
config.store = RuntimeHttpStoreSelection::Memory;
Ok(())
}
StoreArg::Fs { root } => apply_fs_store_selection(config, root),
StoreArg::Fs { root } => {
let root = root.ok_or_else(|| {
ProcessError::usage("--store fs requires --fs-root <PATH>".to_string())
})?;
config.store = RuntimeHttpStoreSelection::Fs { root };
Ok(())
}
}
}
#[cfg(feature = "fs-store")]
fn apply_fs_store_selection(
config: &mut RuntimeHttpServerConfig,
root: Option<PathBuf>,
) -> Result<(), ProcessError> {
let root = root
.ok_or_else(|| ProcessError::usage("--store fs requires --fs-root <PATH>".to_string()))?;
config.store = RuntimeHttpStoreSelection::Fs { root };
Ok(())
#[derive(Clone, Debug, PartialEq, Eq)]
struct ProcessConfig {
http: RuntimeHttpServerConfig,
workspace_root: PathBuf,
cwd: PathBuf,
worker_store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
worker_runtime_base_dir: Option<PathBuf>,
profile: Option<String>,
}
#[cfg(not(feature = "fs-store"))]
fn apply_fs_store_selection(
_config: &mut RuntimeHttpServerConfig,
root: Option<PathBuf>,
) -> Result<(), ProcessError> {
let _ = root;
Err(ProcessError::usage(
"fs store selection requires building worker-runtime with features `http-server,fs-store`"
.to_string(),
))
impl ProcessConfig {
fn default() -> Result<Self, ProcessError> {
let workspace_root = env::current_dir()?;
Ok(Self {
http: RuntimeHttpServerConfig::default(),
cwd: workspace_root.clone(),
workspace_root,
worker_store_dir: None,
worker_metadata_dir: None,
worker_runtime_base_dir: None,
profile: None,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -217,6 +307,8 @@ enum StoreArg {
enum ProcessError {
Usage(String),
Server(RuntimeHttpServerError),
Runtime(RuntimeError),
WorkerAdapter(String),
Io(std::io::Error),
}
@ -231,6 +323,8 @@ impl fmt::Display for ProcessError {
match self {
Self::Usage(message) => message.fmt(f),
Self::Server(error) => error.fmt(f),
Self::Runtime(error) => error.fmt(f),
Self::WorkerAdapter(error) => error.fmt(f),
Self::Io(error) => error.fmt(f),
}
}
@ -239,8 +333,9 @@ impl fmt::Display for ProcessError {
impl Error for ProcessError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Usage(_) => None,
Self::Usage(_) | Self::WorkerAdapter(_) => None,
Self::Server(error) => Some(error),
Self::Runtime(error) => Some(error),
Self::Io(error) => Some(error),
}
}
@ -260,14 +355,20 @@ impl From<std::io::Error> for ProcessError {
fn usage() -> &'static str {
"Usage: worker-runtime-rest-server [OPTIONS]\n\n\
Starts the worker-runtime REST command API for a trusted backend/proxy.\n\
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.\n\
Browsers must not connect to this Runtime process directly.\n\n\
Options:\n\
--bind <ADDR> Bind socket address (default: 127.0.0.1:0)\n\
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800)\n\
--runtime-id <ID> Runtime authority id (default: generated)\n\
--display-name <NAME> Runtime display name\n\
--store <memory|fs> Store selection (default: memory)\n\
--fs-root <PATH> Filesystem store root; requires fs-store feature\n\
--workspace <PATH> Workspace root used for spawned Workers (default: cwd)\n\
--cwd <PATH> Process cwd used for spawned Workers (default: workspace)\n\
--profile <SELECTOR> Force spawned Workers to use a Profile selector\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\
--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\
--local-token-env <ENV> Read local bearer token placeholder from env\n\
--max-transcript-projection-items <N> Override transcript projection limit\n\
@ -279,68 +380,84 @@ Options:\n\
mod tests {
use super::*;
#[test]
fn parses_memory_runtime_process_config() {
let config = parse_args([
"--bind",
"127.0.0.1:48181",
"--runtime-id=runtime-review",
"--display-name",
"review runtime",
"--store",
"memory",
"--local-token",
"local-placeholder",
"--max-transcript-projection-items",
"32",
"--max-event-batch-items=16",
])
.unwrap()
.unwrap();
assert_eq!(
config.bind_addr,
"127.0.0.1:48181".parse::<SocketAddr>().unwrap()
);
assert_eq!(
config.runtime_id.as_ref().map(RuntimeId::as_str),
Some("runtime-review")
);
assert_eq!(config.display_name.as_deref(), Some("review runtime"));
assert!(matches!(config.store, RuntimeHttpStoreSelection::Memory));
assert_eq!(config.local_token.as_deref(), Some("local-placeholder"));
assert_eq!(config.limits.max_transcript_projection_items, 32);
assert_eq!(config.limits.max_event_batch_items, 16);
}
#[cfg(feature = "fs-store")]
#[test]
fn parses_fs_store_runtime_process_config_when_feature_enabled() {
let config = parse_args(["--fs-root", "/tmp/yoi-worker-runtime-store"])
.unwrap()
.unwrap();
assert!(matches!(
config.store,
RuntimeHttpStoreSelection::Fs { ref root }
if root == &PathBuf::from("/tmp/yoi-worker-runtime-store")
));
}
#[cfg(not(feature = "fs-store"))]
#[test]
fn rejects_fs_store_runtime_process_config_without_feature() {
let error = parse_args(["--store", "fs", "--fs-root", "/tmp/store"]).unwrap_err();
assert!(
error
.to_string()
.contains("requires building worker-runtime with features")
);
}
#[test]
fn help_does_not_start_server() {
assert!(parse_args(["--help"]).unwrap().is_none());
}
#[test]
fn default_bind_addr_is_stable_for_local_dev() {
let config = parse_args([] as [&str; 0]).unwrap().unwrap();
assert_eq!(
config.http.bind_addr,
"127.0.0.1:38800".parse::<SocketAddr>().unwrap()
);
}
#[test]
fn parses_memory_runtime_process_config() {
let config = parse_args([
"--bind",
"127.0.0.1:0",
"--runtime-id=runtime-alpha",
"--display-name",
"Runtime Alpha",
"--workspace",
"/tmp/workspace",
"--cwd",
"/tmp/workspace/subdir",
"--profile",
"builtin:coder",
"--local-token-env",
"TEST_RUNTIME_TOKEN",
]);
assert!(config.is_err());
unsafe {
env::set_var("TEST_RUNTIME_TOKEN", "secret-token");
}
let config = parse_args([
"--bind",
"127.0.0.1:0",
"--runtime-id=runtime-alpha",
"--display-name",
"Runtime Alpha",
"--workspace",
"/tmp/workspace",
"--cwd",
"/tmp/workspace/subdir",
"--profile",
"builtin:coder",
"--local-token-env",
"TEST_RUNTIME_TOKEN",
])
.unwrap()
.unwrap();
unsafe {
env::remove_var("TEST_RUNTIME_TOKEN");
}
assert_eq!(config.http.bind_addr, "127.0.0.1:0".parse().unwrap());
assert_eq!(
config.http.runtime_id.as_ref().map(ToString::to_string),
Some("runtime-alpha".to_string())
);
assert_eq!(config.http.display_name.as_deref(), Some("Runtime Alpha"));
assert_eq!(config.workspace_root, PathBuf::from("/tmp/workspace"));
assert_eq!(config.cwd, PathBuf::from("/tmp/workspace/subdir"));
assert_eq!(config.profile.as_deref(), Some("builtin:coder"));
assert_eq!(config.http.local_token.as_deref(), Some("secret-token"));
}
#[test]
fn parses_fs_store_runtime_process_config() {
let config = parse_args(["--store", "fs", "--fs-root", "/tmp/runtime-store"])
.unwrap()
.unwrap();
assert!(matches!(
config.http.store,
RuntimeHttpStoreSelection::Fs { ref root } if root == &PathBuf::from("/tmp/runtime-store")
));
}
}

View File

@ -65,4 +65,6 @@ pub struct RuntimeSummary {
pub cancelled_worker_count: usize,
pub diagnostic_count: usize,
pub limits: RuntimeLimits,
#[serde(default)]
pub worker_creation_available: bool,
}

View File

@ -161,6 +161,7 @@ impl Runtime {
cancelled_worker_count,
diagnostic_count: state.diagnostics.len(),
limits: state.limits.clone(),
worker_creation_available: state.execution_backend.is_some(),
})
}

View File

@ -14,6 +14,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::interaction::{WorkerInput, WorkerInputKind};
use async_trait::async_trait;
use manifest::paths;
use protocol::{Method, Segment, WorkerStatus};
@ -21,13 +26,8 @@ use session_store::FsStore;
use session_store::{CombinedStore, FsWorkerStore};
use tokio::runtime::Runtime;
use tokio::sync::broadcast;
use worker_runtime::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use worker_runtime::interaction::{WorkerInput, WorkerInputKind};
use crate::{Worker, WorkerController, WorkerHandle};
use worker::{Worker, WorkerController, WorkerHandle};
const DEFAULT_BACKEND_ID: &str = "worker-crate";
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
@ -115,7 +115,7 @@ impl ProfileRuntimeWorkerFactory {
fn runtime_base_dir(&self) -> Result<PathBuf, String> {
self.runtime_base_dir
.clone()
.or_else(|| crate::runtime::dir::default_base().ok())
.or_else(|| worker::runtime::dir::default_base().ok())
.ok_or_else(|| "could not resolve worker runtime directory".to_string())
}
@ -124,14 +124,14 @@ impl ProfileRuntimeWorkerFactory {
}
fn runtime_profile_value(
profile: &worker_runtime::catalog::ProfileSelector,
profile: &crate::catalog::ProfileSelector,
) -> Option<std::borrow::Cow<'_, str>> {
match profile {
worker_runtime::catalog::ProfileSelector::RuntimeDefault => None,
worker_runtime::catalog::ProfileSelector::Named(name) => {
crate::catalog::ProfileSelector::RuntimeDefault => None,
crate::catalog::ProfileSelector::Named(name) => {
Some(std::borrow::Cow::Borrowed(name.as_str()))
}
worker_runtime::catalog::ProfileSelector::Builtin(name) => {
crate::catalog::ProfileSelector::Builtin(name) => {
if name.starts_with("builtin:") {
Some(std::borrow::Cow::Borrowed(name.as_str()))
} else {
@ -160,7 +160,7 @@ 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) = crate::entrypoint::resolve_runtime_profile_manifest(
let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest(
profile.as_deref(),
&self.workspace_root,
&worker_name,
@ -211,7 +211,7 @@ pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
backend_id: String,
factory: Arc<F>,
runtime: Mutex<Option<Runtime>>,
workers: Mutex<HashMap<worker_runtime::identity::WorkerRef, RuntimeWorkerExecution>>,
workers: Mutex<HashMap<crate::identity::WorkerRef, RuntimeWorkerExecution>>,
}
impl WorkerRuntimeExecutionBackend<ProfileRuntimeWorkerFactory> {
@ -461,7 +461,7 @@ where
},
WorkerExecutionRunState::Busy,
);
if result.outcome != worker_runtime::execution::WorkerExecutionOutcome::Accepted {
if result.outcome != crate::execution::WorkerExecutionOutcome::Accepted {
busy.store(false, Ordering::SeqCst);
}
result
@ -506,17 +506,17 @@ mod tests {
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::Runtime as EmbeddedRuntime;
use crate::catalog::{ConfigBundleRef, CreateWorkerRequest, ProfileSelector};
use crate::identity::RuntimeId;
use crate::management::RuntimeOptions;
use crate::observation::{TranscriptQuery, TranscriptRole};
use async_trait::async_trait;
use futures::Stream;
use llm_engine::Engine;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use manifest::{Scope, WorkerManifest};
use worker_runtime::Runtime as EmbeddedRuntime;
use worker_runtime::catalog::{CreateWorkerRequest, ProfileSelector, WorkerIntent};
use worker_runtime::identity::RuntimeId;
use worker_runtime::management::RuntimeOptions;
use worker_runtime::observation::{TranscriptQuery, TranscriptRole};
#[derive(Clone)]
struct MockClient {
@ -619,17 +619,37 @@ mod tests {
]
}
fn create_request(name: &str) -> CreateWorkerRequest {
CreateWorkerRequest {
intent: WorkerIntent::Role {
role: name.to_string(),
purpose: None,
fn test_bundle() -> crate::config_bundle::ConfigBundle {
crate::config_bundle::ConfigBundle {
metadata: crate::config_bundle::ConfigBundleMetadata {
id: "adapter-test-bundle".to_string(),
digest: String::new(),
revision: "test".to_string(),
workspace_id: "adapter-test".to_string(),
created_at: "test".to_string(),
provenance: crate::config_bundle::ConfigBundleProvenance {
source: "test".to_string(),
detail: None,
},
},
profiles: vec![crate::config_bundle::ConfigProfileDescriptor {
selector: ProfileSelector::RuntimeDefault,
label: Some("adapter-test".to_string()),
}],
declarations: Vec::new(),
}
.with_computed_digest()
}
fn create_request(_name: &str) -> CreateWorkerRequest {
let bundle = test_bundle();
CreateWorkerRequest {
profile: ProfileSelector::RuntimeDefault,
config_bundle: None,
requested_capabilities: Vec::new(),
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
config_bundle: ConfigBundleRef {
id: bundle.metadata.id,
digest: bundle.metadata.digest,
},
initial_input: None,
}
}
@ -637,14 +657,14 @@ mod tests {
fn builtin_profile_selector_is_not_double_prefixed() {
assert_eq!(
ProfileRuntimeWorkerFactory::runtime_profile_value(
&worker_runtime::catalog::ProfileSelector::Builtin("coder".to_string())
&crate::catalog::ProfileSelector::Builtin("coder".to_string())
)
.as_deref(),
Some("builtin:coder")
);
assert_eq!(
ProfileRuntimeWorkerFactory::runtime_profile_value(
&worker_runtime::catalog::ProfileSelector::Builtin("builtin:coder".to_string())
&crate::catalog::ProfileSelector::Builtin("builtin:coder".to_string())
)
.as_deref(),
Some("builtin:coder")
@ -673,6 +693,7 @@ mod tests {
Arc::new(backend),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime.create_worker(create_request("chat")).unwrap();
runtime
@ -700,7 +721,7 @@ mod tests {
let observations = runtime
.read_worker_observation_events(
&detail.worker_ref,
worker_runtime::observation::WorkerObservationCursor::zero(),
crate::observation::WorkerObservationCursor::zero(),
)
.unwrap();
assert!(

View File

@ -7,7 +7,6 @@ autobins = false
[features]
default = []
runtime-adapter = ["dep:worker-runtime"]
[dependencies]
async-trait = { workspace = true }
@ -19,7 +18,6 @@ mcp = { workspace = true }
protocol = { workspace = true }
provider = { workspace = true }
client = { workspace = true }
worker-runtime = { workspace = true, features = ["ws-server"], optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] }

View File

@ -225,8 +225,7 @@ fn load_profile(
Ok((resolved.manifest, PromptLoader::builtins_only()))
}
#[cfg(feature = "runtime-adapter")]
pub(crate) fn resolve_runtime_profile_manifest(
pub fn resolve_runtime_profile_manifest(
profile: Option<&str>,
workspace_root: &Path,
worker_name: &str,

View File

@ -10,8 +10,6 @@ pub(crate) mod in_flight;
pub mod ipc;
pub mod prompt;
pub mod runtime;
#[cfg(feature = "runtime-adapter")]
pub mod runtime_adapter;
pub mod segment_log_sink;
pub mod shared_state;
mod shutdown_after_idle;

View File

@ -23,7 +23,7 @@ thiserror.workspace = true
ticket.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
tokio-tungstenite.workspace = true
worker = { workspace = true, features = ["runtime-adapter"] }
worker.workspace = true
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] }
toml.workspace = true
tracing.workspace = true

View File

@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity;
use crate::repositories::ConfiguredRepository;
use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result};
@ -26,6 +27,8 @@ pub struct WorkspaceBackendConfigFile {
#[serde(default)]
pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
#[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig,
}
@ -58,6 +61,18 @@ pub struct WorkspaceBackendLimitsConfig {
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub default_selector: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig {
@ -264,6 +279,11 @@ impl WorkspaceBackendConfigFile {
.map(|path| resolve_workspace_path(workspace_root, path));
server.embedded_runtime_store_root = embedded_runtime_store_root;
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
server.repositories = self
.repositories
.iter()
.map(|repository| resolve_repository(workspace_root, repository))
.collect::<Result<Vec<_>>>()?;
server.remote_runtime_sources = self
.runtimes
.remote
@ -299,7 +319,73 @@ impl ResolvedWorkspaceBackendConfig {
}
}
fn resolve_remote_runtime(config: &RemoteRuntimeConfigFile) -> Result<RemoteRuntimeConfig> {
fn resolve_repository(
workspace_root: &Path,
config: &WorkspaceRepositoryConfigFile,
) -> Result<ConfiguredRepository> {
let id = normalize_required_string("repository id", &config.id)?;
validate_repository_id(&id)?;
let provider =
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
let uri = normalize_required_string("repository uri", &config.uri)?;
let path = resolve_repository_uri(workspace_root, &id, &uri)?;
let display_name = normalize_optional_string(config.display_name.as_deref());
let default_selector = normalize_optional_string(config.default_selector.as_deref());
Ok(ConfiguredRepository {
id,
provider,
uri,
path,
display_name,
default_selector,
})
}
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(Error::Config(format!("{field} must not be empty")));
}
Ok(trimmed.to_string())
}
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn validate_repository_id(id: &str) -> Result<()> {
if id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
Ok(())
} else {
Err(Error::Config(format!(
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
)))
}
}
fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result<PathBuf> {
if uri.contains("://") {
return Err(Error::Config(format!(
"repository `{id}` uses a remote URI, but remote repository materialization is not implemented"
)));
}
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
}
pub(crate) fn resolve_remote_runtime(
config: &RemoteRuntimeConfigFile,
) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() {
return Err(Error::Config(format!(
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet",
@ -477,6 +563,57 @@ root = ".local-data"
assert!(diff.text.contains("127.0.0.1:9999"));
}
#[test]
fn resolves_repository_uri_relative_to_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "."
display_name = "Main"
default_selector = "HEAD"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
let repository = resolved.server.repositories.first().unwrap();
assert_eq!(repository.id, "main");
assert_eq!(repository.provider, "git");
assert_eq!(repository.path, dir.path());
assert_eq!(repository.display_name.as_deref(), Some("Main"));
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
}
#[test]
fn remote_repository_uri_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "https://example.com/org/repo.git"
"#,
"test",
)
.unwrap();
let error = match config.resolve(dir.path(), identity()) {
Ok(_) => panic!("remote repository URI should fail closed"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("remote repository materialization is not implemented"),
"unexpected error: {error}"
);
}
#[test]
fn token_value_field_is_not_in_schema() {
let error = WorkspaceBackendConfigFile::parse_str(

View File

@ -6,7 +6,11 @@ use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{path::PathBuf, sync::Arc, time::Duration};
use std::{
path::PathBuf,
sync::{Arc, RwLock},
time::Duration,
};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus,
@ -45,6 +49,22 @@ const MAX_HOST_SCAN: usize = 256;
const MAX_IDENTIFIER_LEN: usize = 120;
const ID_DIGEST_HEX_LEN: usize = 16;
fn run_blocking_http<T, F>(operation: F) -> T
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
match tokio::runtime::Handle::try_current() {
Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(operation)
}
Ok(_) => std::thread::spawn(operation)
.join()
.expect("blocking HTTP thread panicked"),
Err(_) => operation(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeDiagnostic {
pub code: String,
@ -643,31 +663,95 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}
}
#[derive(Debug, Clone)]
pub enum RuntimeRegistryUnregisterResult {
Removed,
NotFound,
BlockedByWorkers {
worker_count: usize,
diagnostics: Vec<RuntimeDiagnostic>,
},
}
#[derive(Clone)]
pub struct RuntimeRegistry {
runtimes: Vec<Arc<dyn WorkspaceWorkerRuntime>>,
runtimes: Arc<RwLock<Vec<Arc<dyn WorkspaceWorkerRuntime>>>>,
}
impl RuntimeRegistry {
pub fn new(runtimes: Vec<Arc<dyn WorkspaceWorkerRuntime>>) -> Self {
Self { runtimes }
Self {
runtimes: Arc::new(RwLock::new(runtimes)),
}
}
pub fn for_workspace(embedded_runtime: EmbeddedWorkerRuntime) -> Self {
Self::new(vec![Arc::new(embedded_runtime)])
}
pub fn register<R>(&mut self, runtime: R)
pub fn register<R>(&self, runtime: R)
where
R: WorkspaceWorkerRuntime + 'static,
{
self.runtimes.push(Arc::new(runtime));
self.runtimes
.write()
.expect("runtime registry lock poisoned")
.push(Arc::new(runtime));
}
pub fn register_or_replace<R>(&self, runtime: R)
where
R: WorkspaceWorkerRuntime + 'static,
{
let runtime = Arc::new(runtime);
let runtime_id = runtime.runtime_id().to_string();
let mut runtimes = self
.runtimes
.write()
.expect("runtime registry lock poisoned");
runtimes.retain(|existing| existing.runtime_id() != runtime_id);
runtimes.push(runtime);
}
pub fn unregister_if_idle(
&self,
runtime_id: &str,
worker_scan_limit: usize,
) -> Result<RuntimeRegistryUnregisterResult, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
let runtime = self
.runtimes_snapshot()
.into_iter()
.find(|runtime| runtime.runtime_id() == runtime_id);
if let Some(runtime) = runtime {
let worker_list = runtime.list_workers(worker_scan_limit);
if !worker_list.items.is_empty() {
return Ok(RuntimeRegistryUnregisterResult::BlockedByWorkers {
worker_count: worker_list.items.len(),
diagnostics: worker_list.diagnostics,
});
}
} else {
return Ok(RuntimeRegistryUnregisterResult::NotFound);
}
let mut runtimes = self
.runtimes
.write()
.expect("runtime registry lock poisoned");
let before = runtimes.len();
runtimes.retain(|runtime| runtime.runtime_id() != runtime_id);
if runtimes.len() == before {
Ok(RuntimeRegistryUnregisterResult::NotFound)
} else {
Ok(RuntimeRegistryUnregisterResult::Removed)
}
}
pub fn list_runtimes(&self, limit: usize) -> RuntimeList<RuntimeSummary> {
let mut diagnostics = Vec::new();
let mut items = Vec::new();
for runtime in self.runtimes.iter().take(limit) {
for runtime in self.runtimes_snapshot().iter().take(limit) {
let summary = runtime.runtime_summary(limit);
diagnostics.extend(summary.diagnostics.iter().cloned());
items.push(summary);
@ -679,7 +763,7 @@ impl RuntimeRegistry {
pub fn list_hosts(&self, limit: usize) -> RuntimeList<HostSummary> {
let mut items = Vec::new();
let mut diagnostics = Vec::new();
for runtime in &self.runtimes {
for runtime in self.runtimes_snapshot() {
if items.len() >= limit {
break;
}
@ -694,7 +778,7 @@ impl RuntimeRegistry {
pub fn list_workers(&self, limit: usize) -> RuntimeList<WorkerSummary> {
let mut items = Vec::new();
let mut diagnostics = Vec::new();
for runtime in &self.runtimes {
for runtime in self.runtimes_snapshot() {
if items.len() >= limit {
break;
}
@ -716,7 +800,7 @@ impl RuntimeRegistry {
let mut host_found = false;
let mut diagnostics = Vec::new();
let mut items = Vec::new();
for runtime in &self.runtimes {
for runtime in self.runtimes_snapshot() {
let host_list = runtime.list_hosts(MAX_HOST_SCAN);
diagnostics.extend(host_list.diagnostics);
if !host_list.items.iter().any(|host| host.host_id == host_id) {
@ -894,13 +978,23 @@ impl RuntimeRegistry {
})
}
fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
self.runtimes
.read()
.expect("runtime registry lock poisoned")
.clone()
}
fn runtime(
&self,
runtime_id: &str,
) -> Result<&Arc<dyn WorkspaceWorkerRuntime>, RuntimeRegistryError> {
) -> Result<Arc<dyn WorkspaceWorkerRuntime>, RuntimeRegistryError> {
self.runtimes
.read()
.expect("runtime registry lock poisoned")
.iter()
.find(|runtime| runtime.runtime_id() == runtime_id)
.cloned()
.ok_or_else(|| RuntimeRegistryError::UnknownRuntime(runtime_id.to_string()))
}
}
@ -1548,7 +1642,7 @@ impl RemoteRuntimeConfig {
display_name: display_name.into(),
base_url: base_url.into(),
bearer_token,
cached_capabilities: remote_runtime_capabilities(200, false),
cached_capabilities: remote_runtime_capabilities(200, false, false),
cached_status: "configured".to_string(),
timeout: Duration::from_secs(10),
}
@ -1586,14 +1680,14 @@ impl RemoteWorkerRuntime {
pub fn new(config: RemoteRuntimeConfig) -> Result<Self, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &config.runtime_id)?;
let base_url = config.base_url.trim_end_matches('/').to_string();
let http = BlockingHttpClient::builder()
.timeout(config.timeout)
.build()
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: config.runtime_id.clone(),
code: "remote_runtime_client_build_failed".to_string(),
message: err.to_string(),
})?;
let timeout = config.timeout;
let http =
run_blocking_http(move || BlockingHttpClient::builder().timeout(timeout).build())
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: config.runtime_id.clone(),
code: "remote_runtime_client_build_failed".to_string(),
message: err.to_string(),
})?;
Ok(Self {
host_id: host_id_for_remote_runtime(&config.runtime_id),
runtime_id: config.runtime_id,
@ -1628,18 +1722,9 @@ impl RemoteWorkerRuntime {
format!("{base}/v1/workers/{worker_id}/events/ws")
}
fn authorize(&self, request: RequestBuilder) -> RequestBuilder {
let request = request.header(CONTENT_TYPE, "application/json");
if let Some(token) = self.bearer_token.as_deref() {
request.header(AUTHORIZATION, format!("Bearer {token}"))
} else {
request
}
}
fn get_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned,
T: DeserializeOwned + Send + 'static,
{
self.send_json(self.http.get(self.endpoint(path)))
}
@ -1647,38 +1732,43 @@ impl RemoteWorkerRuntime {
fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, RuntimeDiagnostic>
where
B: Serialize + ?Sized,
T: DeserializeOwned,
T: DeserializeOwned + Send + 'static,
{
self.send_json(self.http.post(self.endpoint(path)).json(body))
}
fn send_json<T>(&self, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned,
T: DeserializeOwned + Send + 'static,
{
let response = self
.authorize(request)
.send()
.map_err(|err| remote_reqwest_diagnostic(&self.runtime_id, err))?;
let status = response.status();
if status.is_success() {
response.json::<T>().map_err(|err| {
diagnostic(
"remote_runtime_malformed_response",
DiagnosticSeverity::Error,
format!(
"Remote Runtime returned malformed JSON for '{}': {err}",
self.runtime_id
),
)
})
} else {
Err(remote_http_status_diagnostic(
&self.runtime_id,
status,
response,
))
}
let runtime_id = self.runtime_id.clone();
let bearer_token = self.bearer_token.clone();
run_blocking_http(move || {
let request = request.header(CONTENT_TYPE, "application/json");
let request = if let Some(token) = bearer_token.as_deref() {
request.header(AUTHORIZATION, format!("Bearer {token}"))
} else {
request
};
let response = request
.send()
.map_err(|err| remote_reqwest_diagnostic(&runtime_id, err))?;
let status = response.status();
if status.is_success() {
response.json::<T>().map_err(|err| {
diagnostic(
"remote_runtime_malformed_response",
DiagnosticSeverity::Error,
format!(
"Remote Runtime returned malformed JSON for '{}': {err}",
runtime_id
),
)
})
} else {
Err(remote_http_status_diagnostic(&runtime_id, status, response))
}
})
}
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
@ -1790,12 +1880,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else {
vec![self.host_id.clone()]
},
capabilities: remote_runtime_capabilities(limit, true),
diagnostics: vec![diagnostic(
"remote_runtime_backend_proxy",
DiagnosticSeverity::Info,
"Remote Runtime is accessed only by backend-owned REST/WS clients".to_string(),
)],
capabilities: remote_runtime_capabilities(
limit,
true,
response.runtime.worker_creation_available,
),
diagnostics: Vec::new(),
},
Err(diagnostic) => RuntimeSummary {
runtime_id: self.runtime_id.clone(),
@ -1827,12 +1917,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
status: "configured".to_string(),
observed_at: Utc::now().to_rfc3339(),
last_seen_at: None,
capabilities: remote_runtime_capabilities(limit, true),
diagnostics: vec![diagnostic(
"remote_runtime_backend_proxy",
DiagnosticSeverity::Info,
"Remote host endpoint and credentials are backend-private".to_string(),
)],
capabilities: remote_runtime_capabilities(limit, true, false),
diagnostics: Vec::new(),
}],
Vec::new(),
)
@ -1916,11 +2002,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
kind: "remote_runtime_worker_created".to_string(),
detail: "worker-runtime REST create endpoint accepted the Worker".to_string(),
}],
diagnostics: vec![diagnostic(
"remote_runtime_backend_proxy",
DiagnosticSeverity::Info,
"Remote create used a backend-owned REST client; browser-facing payload exposes only runtime_id plus worker_id".to_string(),
)],
diagnostics: Vec::new(),
},
Err(diagnostic) => WorkerSpawnResult {
state: WorkerOperationState::Rejected,
@ -2489,14 +2571,18 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
encoded
}
fn remote_runtime_capabilities(limit: usize, available: bool) -> RuntimeCapabilitySummary {
fn remote_runtime_capabilities(
limit: usize,
available: bool,
worker_creation_available: bool,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available,
can_spawn_worker: available && worker_creation_available,
can_stop_worker: available,
can_accept_input: available,
can_accept_input: available && worker_creation_available,
has_workspace_fs: false,
has_shell: false,
has_git: false,
@ -3327,6 +3413,19 @@ mod tests {
);
}
#[tokio::test]
async fn remote_runtime_client_can_initialize_inside_tokio_context() {
let runtime = RemoteWorkerRuntime::new(RemoteRuntimeConfig::new(
"remote:async-init",
"Remote Async Init",
"http://127.0.0.1:9",
None,
))
.unwrap();
assert_eq!(runtime.runtime_id(), "remote:async-init");
}
#[test]
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
let worker_json = worker_json("remote:primary", "worker-remote-1");
@ -3362,7 +3461,7 @@ mod tests {
),
]);
let secret = "secret-token-do-not-leak".to_string();
let mut registry = RuntimeRegistry::new(Vec::new());
let registry = RuntimeRegistry::new(Vec::new());
registry.register(
RemoteWorkerRuntime::new(RemoteRuntimeConfig::new(
"remote:primary",
@ -3559,7 +3658,7 @@ mod tests {
.to_string(),
),
]);
let mut registry = RuntimeRegistry::new(Vec::new());
let registry = RuntimeRegistry::new(Vec::new());
registry.register(
RemoteWorkerRuntime::new(RemoteRuntimeConfig::new(
"remote:primary",
@ -3605,7 +3704,7 @@ mod tests {
401,
json!({ "error": { "code": "unauthorized", "message": "bad token" } }).to_string(),
)]);
let mut registry = RuntimeRegistry::new(Vec::new());
let registry = RuntimeRegistry::new(Vec::new());
registry.register(
RemoteWorkerRuntime::new(RemoteRuntimeConfig::new(
"remote:primary",

View File

@ -23,8 +23,8 @@ pub use records::{
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
};
pub use repositories::{
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader,
RepositoryLogRead, RepositorySummary,
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
};
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};

View File

@ -1,351 +1,379 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
process::Command,
};
use serde::{Deserialize, Serialize};
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
pub type RepositoryId = String;
pub type RepositorySelector = String;
const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
const LOCAL_REPOSITORY_PREFIX: &str = "local-";
const MAX_COMMAND_OUTPUT: usize = 4096;
const DEFAULT_LOG_LIMIT: usize = 10;
const MAX_LOG_LIMIT: usize = 50;
const MAX_FIELD_LEN: usize = 240;
#[derive(Debug, Clone)]
pub struct LocalRepositoryReader {
workspace_root: PathBuf,
workspace_id: String,
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredRepository {
pub id: RepositoryId,
pub provider: String,
pub uri: String,
pub path: PathBuf,
pub display_name: Option<String>,
pub default_selector: Option<RepositorySelector>,
}
impl LocalRepositoryReader {
pub fn new(workspace_root: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
Self {
workspace_root: workspace_root.into(),
workspace_id: workspace_id.into(),
}
}
pub fn list(&self, workspace_display_name: &str) -> Vec<RepositorySummary> {
vec![self.summary(workspace_display_name)]
}
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
let git = inspect_git(&self.workspace_root);
RepositorySummary {
id: Self::repository_id_for_workspace(&self.workspace_id),
display_name: workspace_display_name.to_string(),
kind: "local".to_string(),
workspace_root: self.workspace_root.clone(),
record_authority: "local_workspace_root".to_string(),
git,
}
}
pub fn recent_log(&self, requested_limit: Option<usize>) -> RepositoryLogRead {
let limit = requested_limit
.unwrap_or(DEFAULT_LOG_LIMIT)
.clamp(1, MAX_LOG_LIMIT);
git_log(&self.workspace_root, limit)
}
pub fn repository_id_for_workspace(workspace_id: &str) -> String {
format!(
"{LOCAL_REPOSITORY_PREFIX}{}",
sanitize_identifier_fragment(workspace_id)
)
}
pub fn is_local_repository_id(id: &str, workspace_id: &str) -> bool {
id == LEGACY_LOCAL_REPOSITORY_ID || id == Self::repository_id_for_workspace(workspace_id)
}
}
fn sanitize_identifier_fragment(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut previous_dash = false;
for ch in value.chars() {
let mapped = if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
};
if mapped == '-' {
if !previous_dash {
output.push(mapped);
}
previous_dash = true;
} else {
output.push(mapped);
previous_dash = false;
}
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
"workspace".to_string()
} else {
output
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySummary {
pub id: String,
pub id: RepositoryId,
pub display_name: String,
pub kind: String,
pub workspace_root: PathBuf,
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<RepositorySelector>,
pub record_authority: String,
pub git: GitRepositorySummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub git: Option<GitRepositorySummary>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRepositorySummary {
pub status: String,
pub root: Option<PathBuf>,
pub branch: Option<String>,
pub head: Option<String>,
pub dirty: Option<bool>,
pub dirty_scope: String,
pub remote: Option<GitRemoteSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
pub branch: Option<String>,
pub dirty: bool,
pub remotes: Vec<GitRemoteSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRemoteSummary {
pub name: String,
pub url: String,
pub redacted: bool,
pub fetch_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryDiagnostic {
pub severity: String,
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryListProjection {
pub items: Vec<RepositorySummary>,
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryLogRead {
pub repository_id: RepositoryId,
pub default_selector: Option<RepositorySelector>,
pub limit: usize,
pub commits: Vec<GitCommitSummary>,
pub diagnostics: Vec<RepositoryDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitCommitSummary {
pub hash: String,
pub subject: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub timestamp: String,
pub author_date: String,
pub parents: Vec<String>,
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositoryLogRead {
pub limit: usize,
pub items: Vec<GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepositoryLookupError {
UnknownRepository { id: RepositoryId },
UnsupportedProvider { id: RepositoryId, provider: String },
}
fn inspect_git(workspace_root: &Path) -> GitRepositorySummary {
let mut diagnostics = Vec::new();
let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
Ok(root) => PathBuf::from(root.trim()),
Err(message) => {
diagnostics.push(diagnostic(
"git_unavailable",
"info",
format!("Workspace root is not available as a Git repository: {message}"),
));
return GitRepositorySummary {
status: "unavailable".to_string(),
root: None,
branch: None,
head: None,
dirty: None,
dirty_scope: "tracked_changes_only".to_string(),
remote: None,
diagnostics,
#[derive(Debug, Clone)]
pub struct RepositoryRegistryReader {
repositories: Vec<ConfiguredRepository>,
}
impl RepositoryRegistryReader {
pub fn new(repositories: Vec<ConfiguredRepository>) -> Self {
Self { repositories }
}
pub fn list(&self) -> RepositoryListProjection {
if self.repositories.is_empty() {
return RepositoryListProjection {
items: Vec::new(),
diagnostics: vec![RepositoryDiagnostic {
severity: "warning".to_string(),
code: "repository_config_empty".to_string(),
message: "No repositories are configured for this workspace backend."
.to_string(),
}],
};
}
};
let branch = git_stdout(workspace_root, &["branch", "--show-current"])
.ok()
.map(|value| truncate_field(value.trim(), MAX_FIELD_LEN))
.filter(|value| !value.is_empty())
.or_else(|| Some("detached".to_string()));
let head = match git_stdout(workspace_root, &["rev-parse", "--verify", "HEAD"]) {
Ok(value) => Some(truncate_field(value.trim(), 40)),
Err(message) => {
diagnostics.push(diagnostic(
"git_head_unavailable",
"warn",
format!("Git HEAD summary is unavailable: {message}"),
));
None
RepositoryListProjection {
items: self
.repositories
.iter()
.map(|repository| self.summary_for_config(repository))
.collect(),
diagnostics: Vec::new(),
}
};
let dirty = match git_stdout(
workspace_root,
&["status", "--porcelain=v1", "--untracked-files=no"],
) {
Ok(value) => Some(!value.trim().is_empty()),
Err(message) => {
diagnostics.push(diagnostic(
"git_status_unavailable",
"warn",
format!("Git dirty status is unavailable: {message}"),
));
None
}
};
let remote = match git_stdout(workspace_root, &["remote", "get-url", "origin"]) {
Ok(value) => {
let (url, redacted) = sanitize_remote_url(value.trim());
Some(GitRemoteSummary {
name: "origin".to_string(),
url,
redacted,
})
}
Err(_) => {
diagnostics.push(diagnostic(
"git_origin_remote_missing",
"info",
"No origin remote is configured or visible through the bounded Git summary."
.to_string(),
));
None
}
};
GitRepositorySummary {
status: "available".to_string(),
root: Some(root),
branch,
head,
dirty,
dirty_scope: "tracked_changes_only".to_string(),
remote,
diagnostics,
}
}
fn git_log(workspace_root: &Path, limit: usize) -> RepositoryLogRead {
let mut diagnostics = Vec::new();
if let Err(message) = git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
diagnostics.push(diagnostic(
"git_unavailable",
"info",
format!("Recent Git log is unavailable for this local repository: {message}"),
));
return RepositoryLogRead {
limit,
items: Vec::new(),
diagnostics,
};
}
match git_stdout(
workspace_root,
&[
"log",
"--no-show-signature",
"--date=iso-strict",
"--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e",
"-n",
&limit.to_string(),
],
) {
Ok(output) => RepositoryLogRead {
limit,
items: parse_log(output.as_str()),
diagnostics,
},
Err(message) => {
diagnostics.push(diagnostic(
"git_log_unavailable",
"warn",
format!("Recent Git log is unavailable: {message}"),
));
RepositoryLogRead {
limit,
items: Vec::new(),
diagnostics,
pub fn summary(&self, id: &str) -> Result<RepositorySummary, RepositoryLookupError> {
let repository = self
.find(id)
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?;
Ok(self.summary_for_config(repository))
}
pub fn recent_log(
&self,
id: &str,
limit: Option<usize>,
) -> Result<RepositoryLogRead, RepositoryLookupError> {
let repository = self
.find(id)
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?;
if repository.provider != "git" {
return Err(RepositoryLookupError::UnsupportedProvider {
id: repository.id.clone(),
provider: repository.provider.clone(),
});
}
let limit = limit.unwrap_or(40).clamp(1, 200);
let mut diagnostics = Vec::new();
let commits = match self.git_log(repository, limit) {
Ok(commits) => commits,
Err(message) => {
diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(),
code: "repository_git_log_unavailable".to_string(),
message,
});
Vec::new()
}
};
Ok(RepositoryLogRead {
repository_id: repository.id.clone(),
default_selector: repository.default_selector.clone(),
limit,
commits,
diagnostics,
})
}
fn find(&self, id: &str) -> Option<&ConfiguredRepository> {
self.repositories
.iter()
.find(|repository| repository.id == id)
}
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
let display_name = repository
.display_name
.clone()
.unwrap_or_else(|| repository.id.clone());
let mut diagnostics = Vec::new();
let git = match repository.provider.as_str() {
"git" => match self.inspect_git(repository) {
Ok(git) => Some(git),
Err(message) => {
diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(),
code: "repository_git_unavailable".to_string(),
message,
});
None
}
},
provider => {
diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(),
code: "repository_provider_unsupported".to_string(),
message: format!(
"Repository provider `{provider}` is configured but is not supported by the workspace backend API."
),
});
None
}
};
RepositorySummary {
id: repository.id.clone(),
display_name,
kind: repository.provider.clone(),
provider: repository.provider.clone(),
default_selector: repository.default_selector.clone(),
record_authority: "workspace-backend-config".to_string(),
git,
diagnostics,
}
}
fn inspect_git(
&self,
repository: &ConfiguredRepository,
) -> Result<GitRepositorySummary, String> {
let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?;
let branch = git_stdout(&repository.path, ["branch", "--show-current"])
.ok()
.and_then(|value| non_empty_string(value.trim()));
let status = git_stdout(&repository.path, ["status", "--porcelain"])?;
let remotes = git_stdout(&repository.path, ["remote", "-v"])
.map(|raw| parse_remotes(&raw))
.unwrap_or_default();
Ok(GitRepositorySummary {
status: "available".to_string(),
head: non_empty_string(head.trim()),
branch,
dirty: !status.trim().is_empty(),
remotes,
})
}
fn git_log(
&self,
repository: &ConfiguredRepository,
limit: usize,
) -> Result<Vec<GitCommitSummary>, String> {
let limit_arg = format!("-{limit}");
let output = git_stdout(
&repository.path,
[
"log",
"--date=iso-strict",
"--decorate=short",
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P%x1f%D%x1e",
limit_arg.as_str(),
],
)?;
Ok(parse_git_log(&output))
}
}
fn parse_log(output: &str) -> Vec<GitCommitSummary> {
output
.split('\u{1e}')
fn git_stdout<'a, I>(repository_path: &PathBuf, args: I) -> Result<String, String>
where
I: IntoIterator<Item = &'a str>,
{
let output = Command::new("git")
.arg("-C")
.arg(repository_path)
.args(args)
.output()
.map_err(|_| {
"Git command could not be executed; backend-private path details were omitted."
.to_string()
})?;
if !output.status.success() {
return Err("Git command failed; backend-private path details were omitted.".to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn parse_remotes(raw: &str) -> Vec<GitRemoteSummary> {
let mut seen = BTreeSet::new();
let mut remotes = Vec::new();
for line in raw.lines() {
let mut parts = line.split_whitespace();
let Some(name) = parts.next() else {
continue;
};
let Some(url) = parts.next() else {
continue;
};
if !seen.insert((name.to_string(), url.to_string())) {
continue;
}
remotes.push(GitRemoteSummary {
name: name.to_string(),
fetch_url: sanitize_remote_url(url),
});
}
remotes
}
fn parse_git_log(raw: &str) -> Vec<GitCommitSummary> {
raw.split('\u{1e}')
.filter_map(|record| {
let record = record.trim_matches('\n');
if record.is_empty() {
let trimmed = record.trim_matches('\n').trim_end();
if trimmed.is_empty() {
return None;
}
let mut fields = record.split('\u{1f}');
let mut fields = trimmed.split('\u{1f}');
let hash = fields.next()?.to_string();
let short_hash = fields.next().unwrap_or_default().to_string();
let summary = fields.next().unwrap_or_default().to_string();
let author_name = fields.next().unwrap_or_default().to_string();
let author_email = fields.next().unwrap_or_default().to_string();
let author_date = fields.next().unwrap_or_default().to_string();
let parents = fields
.next()
.unwrap_or_default()
.split_whitespace()
.map(ToString::to_string)
.collect();
let refs = fields
.next()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|reference| !reference.is_empty())
.map(ToString::to_string)
.collect();
Some(GitCommitSummary {
hash: truncate_field(fields.next()?, 40),
author_name: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
author_email: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
timestamp: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
subject: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
hash,
short_hash,
summary,
author_name,
author_email,
author_date,
parents,
refs,
})
})
.collect()
}
fn git_stdout(workspace_root: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.arg("-C")
.arg(workspace_root)
.args(args)
.output()
.map_err(|error| truncate_field(&error.to_string(), MAX_FIELD_LEN))?;
command_stdout(output)
fn sanitize_remote_url(url: &str) -> String {
let trimmed = url.trim();
if is_local_path_like(trimmed) {
return "<redacted-local-path>".to_string();
}
let Some((scheme, rest)) = trimmed.split_once("://") else {
return trimmed.to_string();
};
if scheme.eq_ignore_ascii_case("file") {
return "file://<redacted-local-path>".to_string();
}
let Some((_credentials, host_path)) = rest.split_once('@') else {
return trimmed.to_string();
};
format!("{scheme}://<redacted>@{host_path}")
}
fn command_stdout(output: Output) -> Result<String, String> {
if output.status.success() {
return Ok(truncate_output(
String::from_utf8_lossy(&output.stdout).as_ref(),
));
}
let stderr = truncate_output(String::from_utf8_lossy(&output.stderr).as_ref());
if stderr.trim().is_empty() {
Err(format!("git exited with status {}", output.status))
fn is_local_path_like(value: &str) -> bool {
Path::new(value).is_absolute() || is_windows_absolute_path_like(value)
}
fn is_windows_absolute_path_like(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
}
fn non_empty_string(value: &str) -> Option<String> {
if value.is_empty() {
None
} else {
Err(stderr.trim().to_string())
}
}
fn sanitize_remote_url(raw: &str) -> (String, bool) {
let bounded = truncate_field(raw, MAX_FIELD_LEN);
let Some(separator) = bounded.find("://") else {
return (bounded, false);
};
let scheme_end = separator + 3;
let after_scheme = &bounded[scheme_end..];
let Some(at_index) = after_scheme.find('@') else {
return (bounded, false);
};
let host_and_path = &after_scheme[(at_index + 1)..];
(format!("{}{}", &bounded[..scheme_end], host_and_path), true)
}
fn truncate_output(value: &str) -> String {
truncate_field(value, MAX_COMMAND_OUTPUT)
}
fn truncate_field(value: &str, limit: usize) -> String {
if value.len() <= limit {
return value.to_string();
}
let mut end = limit;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: code.to_string(),
severity: match severity {
"error" => DiagnosticSeverity::Error,
"warning" => DiagnosticSeverity::Warning,
_ => DiagnosticSeverity::Info,
},
message,
Some(value.to_string())
}
}
@ -354,24 +382,55 @@ mod tests {
use super::*;
#[test]
fn sanitizes_userinfo_from_url_remotes() {
fn sanitizes_remote_credentials_and_local_paths() {
assert_eq!(
sanitize_remote_url("https://token@example.com/org/repo.git"),
("https://example.com/org/repo.git".to_string(), true)
sanitize_remote_url("https://user:token@example.com/org/repo.git"),
"https://<redacted>@example.com/org/repo.git"
);
assert_eq!(
sanitize_remote_url("git@example.com:org/repo.git"),
("git@example.com:org/repo.git".to_string(), false)
"git@example.com:org/repo.git"
);
assert_eq!(
sanitize_remote_url("/home/alice/private/repo.git"),
"<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("/Users/alice/private/repo.git"),
"<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("C:\\Users\\alice\\private\\repo.git"),
"<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("file:///home/alice/private/repo.git"),
"file://<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("file://localhost/home/alice/private/repo.git"),
"file://<redacted-local-path>"
);
}
#[test]
fn parses_bounded_git_log_records() {
let parsed = parse_log(
"0123456789abcdef\u{1f}Alice\u{1f}a@example.test\u{1f}2026-01-01T00:00:00+00:00\u{1f}Subject\u{1e}\n",
fn empty_registry_reports_diagnostic_without_implicit_repository() {
let projection = RepositoryRegistryReader::new(Vec::new()).list();
assert!(projection.items.is_empty());
assert_eq!(projection.diagnostics.len(), 1);
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
}
#[test]
fn unknown_repository_is_not_resolved_from_fallback() {
let reader = RepositoryRegistryReader::new(Vec::new());
assert_eq!(
reader.summary("main").unwrap_err(),
RepositoryLookupError::UnknownRepository {
id: "main".to_string()
}
);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].hash, "0123456789abcdef");
assert_eq!(parsed[0].subject, "Subject");
}
}

View File

@ -12,20 +12,20 @@ use chrono::{SecondsFormat, Utc};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker::runtime_adapter::WorkerRuntimeExecutionBackend;
use worker_runtime::worker_backend::WorkerRuntimeExecutionBackend;
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
};
use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile};
use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile, resolve_remote_runtime};
use crate::hosts::{
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry,
RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest,
WorkerLifecycleResult, WorkerOperationState, WorkerSpawnAcceptanceRequirement,
WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary,
WorkerTranscriptProjection,
RuntimeRegistryUnregisterResult, RuntimeSummary, WorkerInputRequest, WorkerInputResult,
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
WorkerSummary, WorkerTranscriptProjection,
};
use crate::identity::WorkspaceIdentity;
use crate::observation::{
@ -35,7 +35,10 @@ use crate::observation::{
use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
};
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
use crate::repositories::{
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
RepositoryRegistryReader, RepositorySummary,
};
use crate::store::{ControlPlaneStore, WorkspaceRecord};
use crate::{Error, Result};
use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector};
@ -68,6 +71,7 @@ pub struct ServerConfig {
pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig,
pub max_records: usize,
pub repositories: Vec<ConfiguredRepository>,
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
}
@ -89,6 +93,7 @@ impl ServerConfig {
token_configured: false,
},
max_records: 200,
repositories: Vec::new(),
runtime_event_sources: Vec::new(),
remote_runtime_sources: Vec::new(),
}
@ -171,7 +176,7 @@ impl WorkspaceApi {
updated_at: config.workspace_created_at.clone(),
})
.await?;
let mut runtime = RuntimeRegistry::for_workspace(
let runtime = RuntimeRegistry::for_workspace(
EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
config.workspace_id.clone(),
config.embedded_runtime_store_root.clone(),
@ -202,19 +207,8 @@ impl WorkspaceApi {
self.config.workspace_id.as_str()
}
fn local_repository_reader(&self) -> LocalRepositoryReader {
LocalRepositoryReader::new(
self.config.workspace_root.clone(),
self.config.workspace_id.clone(),
)
}
fn local_repository_id(&self) -> String {
LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
}
fn workspace_display_name(&self) -> &str {
self.config.workspace_display_name.as_str()
fn repository_reader(&self) -> RepositoryRegistryReader {
RepositoryRegistryReader::new(self.config.repositories.clone())
}
}
@ -479,6 +473,8 @@ pub struct RepositoryDetailResponse {
pub struct RepositoryLogResponse {
pub workspace_id: String,
pub repository_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_selector: Option<String>,
pub limit: usize,
pub items: Vec<crate::repositories::GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
@ -633,13 +629,12 @@ async fn get_objective(
async fn list_repositories(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RepositoryListResponse>> {
let reader = api.local_repository_reader();
let items = reader.list(api.workspace_display_name());
let RepositoryListProjection { items, diagnostics } = api.repository_reader().list();
Ok(Json(RepositoryListResponse {
workspace_id: api.config.workspace_id,
items,
source: "local_workspace_root".to_string(),
diagnostics: Vec::new(),
source: "workspace_backend_config".to_string(),
diagnostics: repository_diagnostics(diagnostics),
}))
}
@ -647,12 +642,11 @@ async fn repository_detail(
State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
) -> ApiResult<Json<RepositoryDetailResponse>> {
let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let reader = api.local_repository_reader();
let item = repository_lookup(api.repository_reader().summary(&repository_id))?;
Ok(Json(RepositoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
item: reader.summary(api.workspace_display_name()),
source: "local_workspace_root".to_string(),
item,
source: "workspace_backend_config".to_string(),
}))
}
@ -661,18 +655,23 @@ async fn repository_log(
AxumPath(repository_id): AxumPath<String>,
Query(query): Query<LogQuery>,
) -> ApiResult<Json<RepositoryLogResponse>> {
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
let RepositoryLogRead {
repository_id,
default_selector,
limit,
items,
commits,
diagnostics,
} = api.local_repository_reader().recent_log(query.limit);
} = repository_lookup(
api.repository_reader()
.recent_log(&repository_id, query.limit),
)?;
Ok(Json(RepositoryLogResponse {
workspace_id: api.config.workspace_id,
repository_id: canonical_repository_id,
repository_id,
default_selector,
limit,
items,
diagnostics,
items: commits,
diagnostics: repository_diagnostics(diagnostics),
}))
}
@ -681,7 +680,8 @@ async fn repository_tickets(
AxumPath(repository_id): AxumPath<String>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> {
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
repository_lookup(api.repository_reader().summary(&repository_id))?;
let canonical_repository_id = repository_id;
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let ProjectRecordList {
items,
@ -782,7 +782,7 @@ async fn add_remote_runtime_connection(
"a remote Runtime connection with that id is already configured",
));
}
local_config.runtimes.remote.push(RemoteRuntimeConfigFile {
let remote_config = RemoteRuntimeConfigFile {
id,
endpoint: request.endpoint.trim().to_string(),
display_name: request
@ -792,9 +792,30 @@ async fn add_remote_runtime_connection(
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
token_ref: None,
});
};
let active_config = remote_runtime_config_from_file(&remote_config).map_err(|diagnostic| {
ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id: remote_config.id.clone(),
code: diagnostic.code.clone(),
message: diagnostic.message.clone(),
},
vec![diagnostic],
)
})?;
let active_runtime = RemoteWorkerRuntime::new(active_config).map_err(|err| err.into_error())?;
local_config.runtimes.remote.push(remote_config);
write_workspace_backend_config_for_settings(&api, &local_config)?;
let mut response = runtime_connection_mutation_response(&api, &local_config);
api.runtime.register_or_replace(active_runtime);
let mut response = runtime_connection_mutation_response(
&api,
&local_config,
vec![settings_diagnostic(
"runtime_registry_applied",
DiagnosticSeverity::Info,
"Remote Runtime config was persisted and applied to the active Runtime registry without restarting the Workspace backend.",
)],
);
response.diagnostics.push(settings_diagnostic(
"workspace_backend_config_rewritten",
DiagnosticSeverity::Info,
@ -822,8 +843,44 @@ async fn delete_remote_runtime_connection(
if before == local_config.runtimes.remote.len() {
return Err(Error::UnknownRuntime(runtime_id).into());
}
match api
.runtime
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
.map_err(|err| err.into_error())?
{
RuntimeRegistryUnregisterResult::Removed | RuntimeRegistryUnregisterResult::NotFound => {}
RuntimeRegistryUnregisterResult::BlockedByWorkers {
worker_count,
diagnostics,
} => {
let mut diagnostics = diagnostics;
diagnostics.push(settings_diagnostic(
"remote_runtime_delete_blocked",
DiagnosticSeverity::Error,
format!(
"Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting the connection."
),
));
return Err(ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id,
code: "remote_runtime_delete_blocked".to_string(),
message: "Remote Runtime connection has active workers".to_string(),
},
diagnostics,
));
}
}
write_workspace_backend_config_for_settings(&api, &local_config)?;
let mut response = runtime_connection_mutation_response(&api, &local_config);
let mut response = runtime_connection_mutation_response(
&api,
&local_config,
vec![settings_diagnostic(
"runtime_registry_applied",
DiagnosticSeverity::Info,
"Remote Runtime config was removed from persisted config and the active Runtime registry without restarting the Workspace backend.",
)],
);
response.diagnostics.push(settings_diagnostic(
"workspace_backend_config_rewritten",
DiagnosticSeverity::Info,
@ -1277,16 +1334,13 @@ fn runtime_connection_settings_response(
fn runtime_connection_mutation_response(
api: &WorkspaceApi,
local_config: &WorkspaceBackendConfigFile,
diagnostics: Vec<RuntimeDiagnostic>,
) -> RuntimeConnectionMutationResponse {
RuntimeConnectionMutationResponse {
workspace_id: api.config.workspace_id.clone(),
restart_required: true,
remotes: remote_runtime_connection_summaries(api, local_config, true),
diagnostics: vec![settings_diagnostic(
"runtime_registry_restart_required",
DiagnosticSeverity::Warning,
"Runtime connection config changed; restart the Workspace backend for the live Runtime registry to use the new config.",
)],
restart_required: false,
remotes: remote_runtime_connection_summaries(api, local_config, false),
diagnostics,
}
}
@ -1443,6 +1497,18 @@ fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> {
Ok(())
}
fn remote_runtime_config_from_file(
remote: &RemoteRuntimeConfigFile,
) -> std::result::Result<RemoteRuntimeConfig, RuntimeDiagnostic> {
resolve_remote_runtime(remote).map_err(|err| {
settings_diagnostic(
"remote_runtime_apply_failed",
DiagnosticSeverity::Error,
err.to_string(),
)
})
}
async fn test_remote_runtime_config(
api: &WorkspaceApi,
remote: &RemoteRuntimeConfigFile,
@ -1529,7 +1595,10 @@ async fn test_remote_runtime_config(
);
}
};
observation.available("runtime.summary", "Runtime summary was readable.");
observation.available(
"runtime.summary",
"Connected: /v1/runtime responded with a recognized worker-runtime summary.",
);
let workers_url = match remote_probe_url(remote, "/v1/workers") {
Ok(url) => url,
@ -1544,7 +1613,10 @@ async fn test_remote_runtime_config(
match probe_remote_json(&client, workers_url, "workers.list", "Worker list").await {
Ok(payload) => match serde_json::from_value::<RuntimeHttpWorkersResponse>(payload) {
Ok(workers) => {
observation.available("workers.list", "Worker list was readable.");
observation.available(
"workers.list",
"Verified: /v1/workers responded with a recognized worker list.",
);
Some(workers)
}
Err(_) => {
@ -1574,7 +1646,10 @@ async fn test_remote_runtime_config(
match remote_probe_url(remote, &path) {
Ok(url) => match probe_remote_json(&client, url, "workers.detail", "Worker detail").await {
Ok(payload) => match serde_json::from_value::<RuntimeHttpWorkerResponse>(payload) {
Ok(_) => observation.available("workers.detail", "Worker detail was readable."),
Ok(_) => observation.available(
"workers.detail",
"Verified: worker detail responded for an existing worker reported by the remote Runtime.",
),
Err(_) => observation.incompatible(
"workers.detail",
settings_diagnostic(
@ -1591,13 +1666,13 @@ async fn test_remote_runtime_config(
} else {
observation.unknown(
"workers.detail",
"Worker detail compatibility could not be proven because the Runtime reported no workers during the lightweight probe.",
"No connection problem found. Worker detail was not checked because the remote Runtime reported no workers during the lightweight probe.",
);
}
observation.available(
"workers.events_ws.construct",
"Worker event websocket URL can be constructed from the configured HTTP(S) Runtime endpoint, but no websocket connection was opened during this lightweight test.",
"Verified: worker event websocket URL can be constructed from the configured HTTP(S) Runtime endpoint. The lightweight test does not open a websocket stream.",
);
let bundles_url = match remote_probe_url(remote, "/v1/config-bundles") {
@ -1621,8 +1696,10 @@ async fn test_remote_runtime_config(
Ok(payload) => {
match serde_json::from_value::<RuntimeHttpConfigBundlesResponse>(payload) {
Ok(bundles) => {
observation
.available("config_bundles.list", "Config-bundle list was readable.");
observation.available(
"config_bundles.list",
"Verified: /v1/config-bundles responded with a recognized config-bundle list.",
);
Some(bundles)
}
Err(_) => {
@ -1665,7 +1742,7 @@ async fn test_remote_runtime_config(
{
Ok(_) => observation.available(
"config_bundles.availability",
"Config-bundle availability was readable for an advertised bundle.",
"Verified: config-bundle availability was confirmed for an advertised bundle.",
),
Err(_) => observation.incompatible(
"config_bundles.availability",
@ -1686,21 +1763,32 @@ async fn test_remote_runtime_config(
} else {
observation.unknown(
"config_bundles.availability",
"Config-bundle availability compatibility could not be proven because the Runtime advertised no bundles during the lightweight probe.",
"No connection problem found. Config-bundle availability was not checked because the remote Runtime advertised no bundles during the lightweight probe.",
);
}
observation.unknown(
"workers.spawn",
"Worker spawn compatibility was not proven because the lightweight test does not create remote workers as a side effect.",
);
if summary.runtime.worker_creation_available {
observation.available(
"workers.spawn",
"Verified: /v1/runtime reports worker creation is enabled by a Runtime execution backend. The lightweight test does not create a worker.",
);
} else {
observation.incompatible(
"workers.spawn",
settings_diagnostic(
"remote_runtime_worker_creation_unavailable",
DiagnosticSeverity::Error,
"Connected to the Runtime, but worker creation is unavailable because this Runtime process has no execution backend attached.",
),
);
}
observation.unknown(
"workers.input_dispatch",
"Worker input dispatch compatibility was not proven because the lightweight test does not send model-visible input as a side effect.",
"No connection problem found. Worker input dispatch was not checked because this lightweight test does not send model-visible input as a side effect.",
);
observation.unknown(
"config_bundles.sync",
"Config-bundle sync compatibility was not proven because the lightweight test does not upload bundles as a side effect.",
"No connection problem found. Config-bundle sync was not checked because this lightweight test does not upload bundles as a side effect.",
);
RemoteRuntimeTestResponse {
@ -1709,11 +1797,14 @@ async fn test_remote_runtime_config(
checked_at,
state: observation.state().to_string(),
protocol_version,
compatibility_basis: "Observed worker-runtime HTTP endpoints for summary, worker listing/detail when available, event websocket URL construction, and config-bundle listing/availability when available; side-effecting spawn/input/sync operations are reported unknown unless the Runtime API proves them.".to_string(),
compatibility_basis: "Connected to /v1/runtime and verified non-side-effecting worker-runtime HTTP endpoints. No incompatible operation was found; warning items below are unproven optional or side-effecting checks, not connection failures.".to_string(),
capabilities: observation.capabilities,
health_result: format!(
"runtime_status={:?}; incompatible={}; unknown={}",
summary.runtime.status, observation.incompatible_count, observation.unknown_count
"connected=true; runtime_status={:?}; available={}; incompatible={}; warnings={}",
summary.runtime.status,
observation.available_count,
observation.incompatible_count,
observation.unknown_count
),
diagnostics: observation.diagnostics,
}
@ -1747,13 +1838,20 @@ fn remote_runtime_test_failed(
struct RuntimeCompatibilityObservation {
capabilities: Vec<String>,
diagnostics: Vec<RuntimeDiagnostic>,
available_count: usize,
incompatible_count: usize,
unknown_count: usize,
}
impl RuntimeCompatibilityObservation {
fn available(&mut self, operation: &str, _message: &str) {
fn available(&mut self, operation: &str, message: impl Into<String>) {
self.available_count += 1;
self.capabilities.push(format!("{operation}:available"));
self.diagnostics.push(settings_diagnostic(
format!("{operation}.available"),
DiagnosticSeverity::Info,
message,
));
}
fn unknown(&mut self, operation: &str, message: impl Into<String>) {
@ -1775,8 +1873,6 @@ impl RuntimeCompatibilityObservation {
fn state(&self) -> &'static str {
if self.incompatible_count > 0 {
"incompatible"
} else if self.unknown_count > 0 {
"unknown"
} else {
"compatible"
}
@ -1960,13 +2056,58 @@ fn sanitize_backend_error(_message: &str) -> String {
"operation failed; backend-private details were omitted".to_string()
}
fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
let canonical_repository_id = api.local_repository_id();
if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
Ok(canonical_repository_id)
} else {
Err(Error::UnknownRepository(repository_id.to_string()))
}
fn repository_diagnostics(
diagnostics: Vec<crate::repositories::RepositoryDiagnostic>,
) -> Vec<RuntimeDiagnostic> {
diagnostics
.into_iter()
.map(|diagnostic| RuntimeDiagnostic {
code: diagnostic.code,
severity: match diagnostic.severity.as_str() {
"error" => DiagnosticSeverity::Error,
"warning" => DiagnosticSeverity::Warning,
_ => DiagnosticSeverity::Info,
},
message: diagnostic.message,
})
.collect()
}
fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -> ApiResult<T> {
result.map_err(|error| match error {
RepositoryLookupError::UnknownRepository { id } => {
let message = format!("repository `{id}` is not configured for this workspace");
ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id: "workspace-repository-registry".to_string(),
code: "repository_not_configured".to_string(),
message: message.clone(),
},
vec![RuntimeDiagnostic {
code: "repository_not_configured".to_string(),
severity: DiagnosticSeverity::Error,
message,
}],
)
}
RepositoryLookupError::UnsupportedProvider { id, provider } => {
let message = format!(
"repository `{id}` uses unsupported provider `{provider}` for this operation"
);
ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id: "workspace-repository-registry".to_string(),
code: "repository_provider_unsupported".to_string(),
message: message.clone(),
},
vec![RuntimeDiagnostic {
code: "repository_provider_unsupported".to_string(),
severity: DiagnosticSeverity::Error,
message,
}],
)
}
})
}
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
@ -2135,6 +2276,14 @@ impl IntoResponse for ApiError {
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
Error::Ticket(_) => StatusCode::NOT_FOUND,
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
StatusCode::NOT_FOUND
}
Error::RuntimeOperationFailed { code, .. }
if code == "repository_provider_unsupported" =>
{
StatusCode::BAD_REQUEST
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
StatusCode::UNAUTHORIZED
}
@ -2144,6 +2293,9 @@ impl IntoResponse for ApiError {
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
StatusCode::NOT_IMPLEMENTED
}
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
StatusCode::CONFLICT
}
Error::RuntimeOperationFailed { code, .. }
if code.starts_with("workspace_settings_")
|| code.starts_with("invalid_")
@ -2191,7 +2343,7 @@ mod tests {
use crate::store::SqliteWorkspaceStore;
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_REPOSITORY_ID: &str = "main";
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z";
#[test]
@ -2303,8 +2455,17 @@ mod tests {
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
let workspace_root = workspace_root.into();
let store_root = workspace_root.join(".test-embedded-runtime-store");
ServerConfig::local_dev(workspace_root, test_identity())
.with_embedded_runtime_store_root(store_root)
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
.with_embedded_runtime_store_root(store_root);
config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(),
provider: "git".to_string(),
uri: ".".to_string(),
path: workspace_root,
display_name: Some("Test Repository".to_string()),
default_selector: Some("HEAD".to_string()),
}];
config
}
async fn test_app(workspace_root: impl Into<PathBuf>) -> Router {
@ -2365,7 +2526,7 @@ mod tests {
}
#[tokio::test]
async fn runtime_connection_settings_add_delete_persist_restart_required() {
async fn runtime_connection_settings_add_delete_apply_live_registry() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
@ -2383,9 +2544,16 @@ mod tests {
}),
)
.await;
assert_eq!(added["restart_required"], true);
assert_eq!(added["restart_required"], false);
assert_eq!(added["remotes"][0]["runtime_id"], "team-runtime");
assert_eq!(added["remotes"][0]["endpoint_configured"], true);
assert!(
added["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| diagnostic["code"] == "runtime_registry_applied")
);
let projected = serde_json::to_string(&added).unwrap();
assert!(!projected.contains("runtime.example.invalid"));
@ -2397,22 +2565,113 @@ mod tests {
"https://runtime.example.invalid"
);
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
assert!(
launch_options["runtimes"]
.as_array()
.unwrap()
.iter()
.any(|runtime| runtime["runtime_id"] == "team-runtime")
);
let deleted = request_json(
app,
app.clone(),
"DELETE",
"/api/settings/runtime-connections/remotes/team-runtime",
None,
StatusCode::OK,
)
.await;
assert_eq!(deleted["restart_required"], true);
assert_eq!(deleted["restart_required"], false);
assert_eq!(deleted["remotes"].as_array().unwrap().len(), 0);
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
assert!(
!launch_options["runtimes"]
.as_array()
.unwrap()
.iter()
.any(|runtime| runtime["runtime_id"] == "team-runtime")
);
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
assert!(persisted.runtimes.remote.is_empty());
}
#[tokio::test]
async fn runtime_connection_test_reports_observed_and_unknown_capabilities_without_endpoint_leak()
#[tokio::test(flavor = "multi_thread")]
async fn runtime_connection_delete_rejects_active_remote_workers() {
let (runtime, _worker_ref) = runtime_with_worker();
let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let runtime_addr = runtime_listener.local_addr().unwrap();
tokio::spawn({
let runtime = runtime.clone();
async move {
worker_runtime::http_server::serve_runtime_http(runtime, runtime_listener, None)
.await
.unwrap()
}
});
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
let added = post_json(
app.clone(),
"/api/settings/runtime-connections/remotes",
serde_json::json!({
"runtime_id": "busy-runtime",
"display_name": "Busy Runtime",
"endpoint": format!("http://{runtime_addr}")
}),
)
.await;
assert_eq!(added["restart_required"], false);
let created = post_json(
app.clone(),
"/api/workers",
serde_json::json!({
"runtime_id": "busy-runtime",
"display_name": "Remote Test Worker",
"profile": "runtime_default",
"initial_text": ""
}),
)
.await;
assert_eq!(created["runtime_id"], "busy-runtime");
let workers = get_json(app.clone(), "/api/workers").await;
assert!(
workers["items"]
.as_array()
.unwrap()
.iter()
.any(|worker| worker["runtime_id"] == "busy-runtime"),
"expected remote runtime to report at least one worker, got {workers}"
);
let response = request_json(
app,
"DELETE",
"/api/settings/runtime-connections/remotes/busy-runtime",
None,
StatusCode::CONFLICT,
)
.await;
assert!(
response["message"]
.as_str()
.unwrap()
.contains("remote_runtime_delete_blocked")
);
assert!(
response["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" })
);
let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
assert_eq!(persisted.runtimes.remote.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn runtime_connection_test_reports_compatible_with_unknown_warnings_without_endpoint_leak()
{
let (runtime, _worker_ref) = runtime_with_worker();
let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@ -2449,7 +2708,7 @@ mod tests {
serde_json::json!({}),
)
.await;
assert_eq!(response["state"], "unknown");
assert_eq!(response["state"], "compatible");
let capabilities = response["capabilities"].as_array().unwrap();
assert!(
capabilities
@ -2464,14 +2723,14 @@ mod tests {
assert!(
capabilities
.iter()
.any(|value| value == "workers.spawn:unknown")
.any(|value| value == "workers.spawn:available")
);
assert!(
response["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| { diagnostic["code"] == "workers.spawn.unknown" })
.any(|diagnostic| { diagnostic["code"] == "workers.spawn.available" })
);
let projected = serde_json::to_string(&response).unwrap();
assert!(!projected.contains(&endpoint));
@ -2479,6 +2738,60 @@ mod tests {
assert_eq!(response["protocol_version"], serde_json::Value::Null);
}
#[tokio::test(flavor = "multi_thread")]
async fn runtime_connection_test_marks_missing_execution_backend_incompatible() {
let runtime =
worker_runtime::Runtime::with_options(worker_runtime::RuntimeOptions::default());
let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let runtime_addr = runtime_listener.local_addr().unwrap();
tokio::spawn(async move {
worker_runtime::http_server::serve_runtime_http(runtime, runtime_listener, None)
.await
.unwrap()
});
let dir = tempfile::tempdir().unwrap();
let endpoint = format!("http://{runtime_addr}");
WorkspaceBackendConfigFile {
runtimes: crate::config::WorkspaceBackendRuntimesConfig {
remote: vec![RemoteRuntimeConfigFile {
id: "control-only-runtime".to_string(),
display_name: Some("Control-only Runtime".to_string()),
endpoint,
token_ref: None,
}],
},
..WorkspaceBackendConfigFile::default()
}
.write_for_workspace(dir.path())
.unwrap();
let app = test_app(dir.path()).await;
let response = post_json(
app,
"/api/settings/runtime-connections/remotes/control-only-runtime/test",
serde_json::json!({}),
)
.await;
assert_eq!(response["state"], "incompatible");
assert!(
response["capabilities"]
.as_array()
.unwrap()
.iter()
.any(|value| { value == "workers.spawn:incompatible" })
);
assert!(
response["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| {
diagnostic["code"] == "remote_runtime_worker_creation_unavailable"
})
);
}
#[tokio::test]
async fn browser_worker_create_succeeds_and_preserves_unsupported_diagnostics() {
let dir = tempfile::tempdir().unwrap();
@ -2610,16 +2923,26 @@ mod tests {
let repositories = get_json(app.clone(), "/api/repositories").await;
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
assert_eq!(repositories["items"][0]["kind"], "local");
assert_eq!(repositories["items"][0]["kind"], "git");
assert_eq!(
repositories["items"][0]["record_authority"],
"workspace-backend-config"
);
assert!(
repositories
.to_string()
.contains("repository_git_unavailable")
);
let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
let repository_detail = get_json(app.clone(), "/api/repositories/main").await;
assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await;
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
assert_eq!(repository_log["default_selector"], "HEAD");
assert_eq!(repository_log["limit"], 3);
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await;
assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
let ready_column = repository_tickets["columns"]
.as_array()
@ -3073,6 +3396,77 @@ mod tests {
}
}
#[tokio::test]
async fn empty_repository_config_returns_empty_list_with_warning() {
let root = tempfile::tempdir().unwrap();
let mut config = test_server_config(root.path());
config.repositories.clear();
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app = build_router(api);
let repositories = get_json(app, "/api/repositories").await;
assert!(repositories["items"].as_array().unwrap().is_empty());
assert_eq!(
repositories["diagnostics"][0]["code"],
"repository_config_empty"
);
}
#[tokio::test]
async fn repository_log_rejects_unknown_or_unsupported_configured_repository() {
let root = tempfile::tempdir().unwrap();
let mut config = test_server_config(root.path());
config.repositories = vec![ConfiguredRepository {
id: "files".to_string(),
provider: "local_fs".to_string(),
uri: ".".to_string(),
path: root.path().to_path_buf(),
display_name: None,
default_selector: None,
}];
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app = build_router(api);
let unknown = request_json(
app.clone(),
"GET",
"/api/repositories/main/log",
None,
StatusCode::NOT_FOUND,
)
.await;
assert_eq!(
unknown["diagnostics"][0]["code"],
"repository_not_configured"
);
let unsupported = request_json(
app,
"GET",
"/api/repositories/files/log",
None,
StatusCode::BAD_REQUEST,
)
.await;
assert_eq!(
unsupported["diagnostics"][0]["code"],
"repository_provider_unsupported"
);
}
#[tokio::test]
async fn embedded_runtime_api_routes_by_runtime_and_worker_ids_without_leaking_internals() {
let dir = tempfile::tempdir().unwrap();

View File

@ -41,11 +41,22 @@ frontend_url = "http://127.0.0.1:5173"
[limits]
max_records = 200
# Repository registry. Browser/API repository projection reads only configured
# entries and never falls back to the backend process cwd. Relative URI values
# are resolved from this workspace config root. Git is the v0 supported provider.
#
# [[repositories]]
# id = "main"
# provider = "git"
# uri = "."
# display_name = "Main repository"
# default_selector = "HEAD"
# Remote Runtime sources. Token values must not be written here.
# Use token_ref only after secret-ref resolution is implemented for this config.
#
# [[runtimes.remote]]
# id = "example"
# endpoint = "http://127.0.0.1:8790"
# endpoint = "http://127.0.0.1:38800"
# display_name = "Example Runtime"
# token_ref = "local:example-runtime-token"

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",
"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",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -1226,28 +1226,74 @@
background: rgba(255, 255, 255, 0.03);
}
.settings-runtime-card {
display: grid;
gap: 0.75rem;
.settings-runtime-table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1rem;
background: var(--bg-raised);
}
.settings-runtime-card header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
.settings-runtime-table {
width: 100%;
min-width: 48rem;
border-collapse: collapse;
}
.settings-runtime-card.inactive {
opacity: 0.86;
.settings-runtime-table th,
.settings-runtime-table td {
padding: 0.8rem 0.9rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.settings-identity-list.compact {
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
.settings-runtime-table th {
color: var(--text-muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.settings-runtime-table tr:last-child td {
border-bottom: 0;
}
.settings-runtime-table tr.inactive {
opacity: 0.78;
}
.settings-runtime-table td {
color: var(--text-muted);
}
.settings-runtime-table td > strong,
.settings-runtime-table td > span,
.settings-runtime-table td > code,
.settings-runtime-table td > small {
display: block;
}
.settings-runtime-table strong {
color: var(--text-strong);
}
.settings-runtime-table code {
margin-top: 0.15rem;
overflow-wrap: anywhere;
}
.settings-runtime-table small {
color: var(--text-muted);
}
.settings-runtime-detail-row td {
padding-top: 0;
background: rgba(255, 255, 255, 0.025);
}
.settings-muted-action {
color: var(--text-muted);
font-size: 0.82rem;
}
.settings-action-row {

View File

@ -1,4 +1,5 @@
<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';
@ -8,6 +9,7 @@
ObjectiveDetail,
ObjectiveListResponse,
RepositoryDetailResponse,
RepositoryListResponse,
RepositorySummary,
RepositoryTicketsResponse,
Worker,
@ -17,19 +19,21 @@
type WorkspaceView = 'overview' | 'repository' | 'objectives' | 'objective';
type RouteState =
| { page: 'overview'; objectiveId?: undefined }
| { page: 'repository'; objectiveId?: undefined }
| { page: 'objectives'; objectiveId?: undefined }
| { page: 'objective'; objectiveId: string };
| { 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
}: { view?: WorkspaceView; repositoryId?: string; objectiveId?: string | null } = $props();
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);
@ -38,57 +42,70 @@
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));
let route = $derived(routeFromView(view, objectiveId, repositoryId));
let currentPath = $derived(pathFromRoute(route));
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
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() {
async function loadWorkspace(signal?: AbortSignal) {
workspaceError = null;
try {
workspace = await getJson<WorkspaceResponse>('/api/workspace');
workspace = await getJson<WorkspaceResponse>('/api/workspace', signal);
} catch (error) {
workspaceError = error instanceof Error ? error.message : String(error);
workspace = null;
}
}
async function loadHosts() {
async function loadHosts(signal?: AbortSignal) {
hostsError = null;
try {
hosts = await getJson<ListResponse<Host>>('/api/hosts');
hosts = await getJson<ListResponse<Host>>('/api/hosts', signal);
} catch (error) {
hostsError = error instanceof Error ? error.message : String(error);
hosts = null;
}
}
async function loadWorkers() {
async function loadWorkers(signal?: AbortSignal) {
workersError = null;
try {
workers = await getJson<ListResponse<Worker>>('/api/workers');
workers = await getJson<ListResponse<Worker>>('/api/workers', signal);
} catch (error) {
workersError = error instanceof Error ? error.message : String(error);
workers = null;
}
}
async function loadRepository() {
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/local');
const detail = await getJson<RepositoryDetailResponse>(
`/api/repositories/${encodeURIComponent(id)}`
);
repository = detail.item;
} catch (error) {
repositoryError = error instanceof Error ? error.message : String(error);
@ -96,20 +113,22 @@
}
}
async function loadRepositoryTickets() {
async function loadRepositoryTickets(id: string) {
repositoryTicketsError = null;
try {
repositoryTickets = await getJson<RepositoryTicketsResponse>('/api/repositories/local/tickets');
repositoryTickets = await getJson<RepositoryTicketsResponse>(
`/api/repositories/${encodeURIComponent(id)}/tickets`
);
} catch (error) {
repositoryTicketsError = error instanceof Error ? error.message : String(error);
repositoryTickets = null;
}
}
async function loadObjectives() {
async function loadObjectives(signal?: AbortSignal) {
objectivesError = null;
try {
objectives = await getJson<ObjectiveListResponse>('/api/objectives');
objectives = await getJson<ObjectiveListResponse>('/api/objectives', signal);
} catch (error) {
objectivesError = error instanceof Error ? error.message : String(error);
objectives = null;
@ -137,9 +156,13 @@
}
}
function routeFromView(view: WorkspaceView, objectiveId: string | null): RouteState {
if (view === 'repository') {
return { page: 'repository' };
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 };
@ -152,7 +175,7 @@
function pathFromRoute(route: RouteState): string {
if (route.page === 'repository') {
return '/repositories/local';
return `/repositories/${route.repositoryId}`;
}
if (route.page === 'objective') {
return `/objectives/${route.objectiveId}`;
@ -167,13 +190,27 @@
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(() => {
void loadWorkspace();
void loadHosts();
void loadWorkers();
void loadRepository();
void loadRepositoryTickets();
void loadObjectives();
if (route.page === 'repository') {
void loadRepository(route.repositoryId);
void loadRepositoryTickets(route.repositoryId);
} else {
repository = null;
repositoryTickets = null;
repositoryError = null;
repositoryTicketsError = null;
}
});
$effect(() => {
@ -199,7 +236,7 @@
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
<WorkspaceSidebar {workspace} {workspaceError} {repositories} {repositoriesError} {currentPath} />
<main class="shell">
{#if route.page === 'repository'}
@ -216,8 +253,12 @@
<dd>{repository.kind}</dd>
</div>
<div>
<dt>Workspace root</dt>
<dd><code>{repository.workspace_root}</code></dd>
<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>
@ -225,13 +266,25 @@
</div>
<div>
<dt>Git</dt>
<dd>{repository.git.status}</dd>
<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/local</code></p>
<p>Waiting for <code>/api/repositories/{route.repositoryId}</code></p>
{/if}
</section>
@ -245,7 +298,7 @@
{:else if repositoryTicketsError}
<p class="error">{repositoryTicketsError}</p>
{:else}
<p>Waiting for <code>/api/repositories/local/tickets</code></p>
<p>Waiting for <code>/api/repositories/{route.repositoryId}/tickets</code></p>
{/if}
</section>

View File

@ -12,7 +12,6 @@
type RemoteRuntimeTestResponse,
type RuntimeConnectionMutationResponse,
type RuntimeConnectionSettingsResponse,
type RuntimeConnectionSummary,
} from "./model";
type RemoteAddForm = {
@ -33,6 +32,7 @@
let deleting = $state<string | null>(null);
let testing = $state<string | null>(null);
let submitting = $state(false);
let showAddRuntimeForm = $state(false);
let remoteForm = $state<RemoteAddForm>({
runtime_id: "",
display_name: "",
@ -117,6 +117,7 @@
const data = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(data);
remoteForm = { runtime_id: "", display_name: "", endpoint: "" };
showAddRuntimeForm = false;
} catch (err) {
mutationMessage = err instanceof Error ? err.message : "add remote Runtime failed";
} finally {
@ -184,6 +185,13 @@
}
}
function capabilityOperations(test: RemoteRuntimeTestResponse, state: "available" | "unknown" | "incompatible"): string[] {
const suffix = `:${state}`;
return test.capabilities
.filter((capability) => capability.endsWith(suffix))
.map((capability) => capability.slice(0, -suffix.length));
}
function applyRuntimeMutation(data: RuntimeConnectionMutationResponse) {
runtimeSettings = runtimeSettings
? { ...runtimeSettings, remotes: data.remotes, diagnostics: data.diagnostics }
@ -282,68 +290,138 @@
{:else if runtimeError}
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
{:else if runtimeSettings}
{@render RuntimeConnectionCard({ connection: runtimeSettings.embedded })}
<div class="settings-action-row">
<button type="button" onclick={() => showAddRuntimeForm = !showAddRuntimeForm}>
{showAddRuntimeForm ? "Cancel adding Runtime" : "Add remote Runtime"}
</button>
</div>
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
<h3>Add remote Runtime</h3>
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
<label>
<span>Runtime id</span>
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
</label>
<label>
<span>Display name</span>
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
</label>
<label>
<span>Endpoint</span>
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
</label>
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
</form>
{#if showAddRuntimeForm}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
<h3>Add remote Runtime</h3>
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
<label>
<span>Runtime id</span>
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
</label>
<label>
<span>Display name</span>
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
</label>
<label>
<span>Endpoint</span>
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
</label>
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
</form>
{/if}
{#if mutationMessage}
<p class="status-message" class:error={mutationMessage.includes("failed")}>{mutationMessage}</p>
{/if}
{@render DiagnosticsList({ diagnostics: mutationDiagnostics })}
<div class="settings-runtime-list" aria-label="Remote Runtime connections">
<h3>Remote Runtimes</h3>
<div class="settings-runtime-list" aria-label="Runtime connections">
<h3>Runtimes</h3>
<div class="settings-runtime-table-wrap">
<table class="settings-runtime-table">
<thead>
<tr>
<th scope="col">Runtime</th>
<th scope="col">Source</th>
<th scope="col">Connection</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr class:inactive={!runtimeSettings.embedded.active}>
<td>
<strong>{runtimeSettings.embedded.display_name}</strong>
<code>{runtimeSettings.embedded.runtime_id}</code>
</td>
<td>
<strong>embedded</strong>
<span>Workspace backend process</span>
</td>
<td>Local Backend runtime</td>
<td>
<span class="badge" class:success={runtimeSettings.embedded.active} class:warning={!runtimeSettings.embedded.active}>{runtimeSettings.embedded.status}</span>
{#if runtimeSettings.embedded.restart_required}
<span class="badge warning">restart required</span>
{/if}
</td>
<td><span class="settings-muted-action">Managed by backend</span></td>
</tr>
{#if runtimeSettings.embedded.diagnostics.length > 0}
<tr class="settings-runtime-detail-row">
<td colspan="5">{@render DiagnosticsList({ diagnostics: runtimeSettings.embedded.diagnostics })}</td>
</tr>
{/if}
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
<tr class:inactive={!remote.active}>
<td>
<strong>{remote.display_name}</strong>
<code>{remote.runtime_id}</code>
</td>
<td>
<strong>remote</strong>
<span>Configured Runtime endpoint</span>
</td>
<td>
<span>Endpoint: {remote.endpoint_configured ? "configured" : "not configured"}</span>
{#if remote.endpoint_configured}<small>hidden</small>{/if}
<span>Token: {remote.token_ref_configured ? "configured" : "not configured"}</span>
</td>
<td>
<span class="badge" class:success={remote.active} class:warning={!remote.active}>{remote.status}</span>
{#if remote.restart_required}
<span class="badge warning">restart required</span>
{/if}
</td>
<td>
<div class="settings-action-row">
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
{testing === remote.runtime_id ? "Testing…" : "Test"}
</button>
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
</button>
</div>
</td>
</tr>
{#if remote.diagnostics.length > 0}
<tr class="settings-runtime-detail-row">
<td colspan="5">{@render DiagnosticsList({ diagnostics: remote.diagnostics })}</td>
</tr>
{/if}
{#if tests[remote.runtime_id]}
{@const test = tests[remote.runtime_id]}
{@const available = capabilityOperations(test, "available")}
{@const unchecked = capabilityOperations(test, "unknown")}
<tr class="settings-runtime-detail-row">
<td colspan="5">
<div class="settings-test-result">
<strong>Test: {test.state}</strong>
<span>{test.health_result} · {test.checked_at}</span>
<p>{test.compatibility_basis}</p>
{#if available.length > 0}
<p class="settings-test-verified">Verified areas: {available.join(', ')}</p>
{/if}
{#if unchecked.length > 0}
<p class="settings-test-verified">Unchecked warning areas: {unchecked.join(', ')}</p>
{/if}
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if runtimeSettings.remotes.length === 0}
<p class="status-message">No remote Runtime connections configured.</p>
{:else}
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
<article class="settings-runtime-card">
{@render RuntimeConnectionCard({ connection: remote })}
<dl class="settings-identity-list compact">
<div>
<dt>Endpoint</dt>
<dd>{remote.endpoint_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
<div>
<dt>Token ref</dt>
<dd>{remote.token_ref_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
</dl>
<div class="settings-action-row">
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
{testing === remote.runtime_id ? "Testing…" : "Test"}
</button>
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
</button>
</div>
{#if tests[remote.runtime_id]}
{@const test = tests[remote.runtime_id]}
<div class="settings-test-result">
<strong>Test: {test.state}</strong>
<span>{test.health_result} · {test.checked_at}</span>
<p>{test.compatibility_basis}</p>
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
</div>
{/if}
</article>
{/each}
{/if}
</div>
{/if}
@ -413,41 +491,6 @@
</main>
</div>
{#snippet RuntimeConnectionCard({ connection }: { connection: RuntimeConnectionSummary | RemoteRuntimeConnectionSummary })}
<article class="settings-runtime-card embedded" class:inactive={!connection.active}>
<header>
<div>
<h3>{connection.display_name}</h3>
<p><code>{connection.runtime_id}</code></p>
</div>
<span class="badge" class:success={connection.active} class:warning={!connection.active}>{connection.status}</span>
</header>
<dl class="settings-identity-list compact">
<div>
<dt>Kind</dt>
<dd>{connection.kind}</dd>
</div>
<div>
<dt>Built in</dt>
<dd>{connection.built_in ? "yes" : "no"}</dd>
</div>
<div>
<dt>Config managed</dt>
<dd>{connection.config_managed ? "yes" : "no"}</dd>
</div>
<div>
<dt>Spawn</dt>
<dd>{connection.can_spawn_worker ? "available" : "unavailable"}</dd>
</div>
<div>
<dt>Restart required</dt>
<dd>{connection.restart_required ? "yes" : "no"}</dd>
</div>
</dl>
{@render DiagnosticsList({ diagnostics: connection.diagnostics })}
</article>
{/snippet}
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
{#if diagnostics.length > 0}
<ul class="settings-diagnostics-list">

View File

@ -1,26 +1,46 @@
<script lang="ts">
import type { WorkspaceResponse } from './types';
import { projectRepositoryNav } from './repository-nav';
import type { RepositoryListResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
repositories: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
};
let { workspace, currentPath = '/' }: Props = $props();
let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
</script>
<section class="nav-section" aria-labelledby="repositories-heading">
<div class="section-heading-row">
<h2 id="repositories-heading">repositories</h2>
<span class="section-count">1</span>
<span class="section-count">{navigation.count}</span>
</div>
<ul class="nav-list" aria-label="Repositories">
<li>
<a class="nav-item" class:active={currentPath.startsWith('/repositories')} href="/repositories/local">
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
<span class="item-meta">local repository · read-only</span>
</a>
</li>
</ul>
{#if repositoriesError}
<p class="nav-empty error">Repository registry unavailable.</p>
{:else if !repositories}
<p class="nav-empty">Loading repositories…</p>
{:else if navigation.items.length === 0}
<p class="nav-empty">No repositories configured.</p>
{#if navigation.diagnostics.length > 0}
<ul class="diagnostics" aria-label="Repository diagnostics">
{#each navigation.diagnostics as diagnostic}
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
{/each}
</ul>
{/if}
{:else}
<ul class="nav-list" aria-label="Repositories">
{#each navigation.items as item (item.id)}
<li>
<a class="nav-item" class:active={item.active} href={item.href} aria-current={item.active ? 'page' : undefined}>
<span class="item-title">{item.title}</span>
<span class="item-meta">{item.meta}</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>

View File

@ -1,17 +1,58 @@
<script lang="ts">
import { onMount } from 'svelte';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import type { WorkspaceResponse } from './types';
import type { RepositoryListResponse, WorkspaceResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
workspaceError?: string | null;
repositories?: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
};
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
let settingsActive = $derived(currentPath.startsWith("/settings"));
let {
workspace,
workspaceError = null,
repositories,
repositoriesError,
currentPath = '/'
}: 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;
}
}
</script>
<aside class="workspace-sidebar" aria-label="Workspace navigation">
@ -32,33 +73,22 @@
<a
class="settings-button"
class:active={settingsActive}
href="/settings"
aria-label="Open Settings / Admin"
title="Settings / Admin"
aria-current={settingsActive ? 'page' : undefined}
>
</a>
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {workspace} {currentPath} />
<RepositoriesNavSection
repositories={displayedRepositories}
repositoriesError={displayedRepositoriesError}
{currentPath}
/>
<ObjectivesNavSection {currentPath} />
<WorkersNavSection {currentPath} />
<section class="nav-section" aria-labelledby="settings-heading">
<div class="section-heading-row">
<h2 id="settings-heading">settings</h2>
</div>
<ul class="nav-list" aria-label="Settings">
<li>
<a class="nav-item" class:active={settingsActive} href="/settings" aria-current={settingsActive ? 'page' : undefined}>
<span class="item-title">Settings / Admin</span>
<span class="item-meta">Backend shell and diagnostics</span>
</a>
</li>
</ul>
</section>
</nav>
</aside>

View File

@ -0,0 +1,70 @@
import { projectRepositoryNav } from "./repository-nav.ts";
import type { RepositoryListResponse } from "./types.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
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 repositories(
items: RepositoryListResponse["items"],
): RepositoryListResponse {
return {
workspace_id: "workspace-1",
items,
source: "workspace_backend_config",
diagnostics: [],
};
}
Deno.test("repository nav does not invent main for an empty registry", () => {
const projection = projectRepositoryNav({
workspace_id: "workspace-1",
items: [],
source: "workspace_backend_config",
diagnostics: [
{
code: "repository_config_empty",
severity: "warning",
message: "No repositories configured",
},
],
});
assertEquals(projection.count, 0);
assertEquals(projection.items, []);
assertEquals(projection.diagnostics[0].code, "repository_config_empty");
});
Deno.test("repository nav links configured non-main repository ids", () => {
const projection = projectRepositoryNav(
repositories([
{
id: "infra",
display_name: "Infrastructure",
kind: "git",
provider: "git",
record_authority: "workspace-backend-config",
git: null,
diagnostics: [],
},
]),
"/repositories/infra",
);
assertEquals(projection.count, 1);
assertEquals(projection.items[0], {
id: "infra",
title: "Infrastructure",
href: "/repositories/infra",
meta: "git repository · read-only",
active: true,
});
});

View File

@ -0,0 +1,36 @@
import type { Diagnostic, RepositoryListResponse } from "./types";
export type RepositoryNavItem = {
id: string;
title: string;
href: string;
meta: string;
active: boolean;
};
export type RepositoryNavProjection = {
count: number;
items: RepositoryNavItem[];
diagnostics: Diagnostic[];
};
export function projectRepositoryNav(
repositories: RepositoryListResponse | null,
currentPath = "/",
): RepositoryNavProjection {
const summaries = repositories?.items ?? [];
return {
count: summaries.length,
diagnostics: repositories?.diagnostics ?? [],
items: summaries.map((repository) => {
const href = `/repositories/${encodeURIComponent(repository.id)}`;
return {
id: repository.id,
title: repository.display_name || repository.id,
href,
meta: `${repository.provider} repository · read-only`,
active: currentPath === href || currentPath.startsWith(`${href}/`),
};
}),
};
}

View File

@ -181,34 +181,42 @@ export type RepositorySummary = {
id: string;
display_name: string;
kind: string;
workspace_root: string;
provider: string;
default_selector?: string | null;
record_authority: string;
git: GitRepositorySummary;
git?: GitRepositorySummary | null;
diagnostics?: Diagnostic[];
};
export type GitRepositorySummary = {
status: string;
root?: string | null;
branch?: string | null;
head?: string | null;
dirty?: boolean | null;
dirty_scope: string;
remote?: GitRemoteSummary | null;
diagnostics: Diagnostic[];
dirty: boolean;
remotes: GitRemoteSummary[];
};
export type GitRemoteSummary = {
name: string;
url: string;
redacted: boolean;
fetch_url: string;
};
export type GitCommitSummary = {
hash: string;
subject: string;
short_hash: string;
summary: string;
author_name: string;
author_email: string;
timestamp: string;
author_date: string;
parents: string[];
refs: string[];
};
export type RepositoryListResponse = {
workspace_id: string;
items: RepositorySummary[];
source: string;
diagnostics: Diagnostic[];
};
export type RepositoryDetailResponse = {
@ -220,6 +228,7 @@ export type RepositoryDetailResponse = {
export type RepositoryLogResponse = {
workspace_id: string;
repository_id: string;
default_selector?: string | null;
limit: number;
items: GitCommitSummary[];
diagnostics: Diagnostic[];

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
</script>
<WorkspacePage view="repository" />
<WorkspacePage view="repository" repositoryId={page.params.repositoryId} />