merge: runtime backend orchestration updates

This commit is contained in:
Keisuke Hirata 2026-06-27 01:28:11 +09:00
commit 390b914268
No known key found for this signature in database
58 changed files with 11274 additions and 90 deletions

View File

@ -1 +1,5 @@
{"id":"orch-plan-20260625-164513-1","ticket_id":"00001KVZ9JGK0","kind":"blocked_by","related_ticket":"00001KVZSGT0Q","note":"Queue routing checked after Dashboard Queue. Backend internal Companion Runtime/Web Console depends on embedded worker-runtime Backend Registry connection `00001KVZSGT0Q`, which is still queued and itself blocked by earlier worker-runtime/core/Backend foundation dependencies. Do not start MVP implementation until that dependency chain is completed.","author":"yoi-orchestrator","at":"2026-06-25T16:45:13Z"} {"id":"orch-plan-20260625-164513-1","ticket_id":"00001KVZ9JGK0","kind":"blocked_by","related_ticket":"00001KVZSGT0Q","note":"Queue routing checked after Dashboard Queue. Backend internal Companion Runtime/Web Console depends on embedded worker-runtime Backend Registry connection `00001KVZSGT0Q`, which is still queued and itself blocked by earlier worker-runtime/core/Backend foundation dependencies. Do not start MVP implementation until that dependency chain is completed.","author":"yoi-orchestrator","at":"2026-06-25T16:45:13Z"}
{"id":"orch-plan-20260625-203613-2","ticket_id":"00001KVZ9JGK0","kind":"blocked_by","related_ticket":"00001KVZKSTJT","note":"Queue routing checked after requeue. Companion Web Console MVP depends on WebSocket/event-stream transport decision/proxy `00001KVZKSTJT` and backend embedded runtime connection. `00001KVZKSTJT` is queued/blocked by REST command server, so this Ticket remains queued.","author":"yoi-orchestrator","at":"2026-06-25T20:36:13Z"}
{"id":"orch-plan-20260626-054930-3","ticket_id":"00001KVZ9JGK0","kind":"waiting_capacity_note","note":"Web Console MVP is left queued while remote Runtime process connection `00001KVZSGT14` is accepted/inprogress. Although embedded Runtime and WS proxy are done, Web Console work would touch similar Backend/API surfaces and should wait until remote source routing stabilizes.","author":"yoi-orchestrator","at":"2026-06-26T05:49:30Z"}
{"id":"orch-plan-20260626-063306-4","ticket_id":"00001KVZ9JGK0","kind":"waiting_capacity_note","note":"Web Console MVP is left queued while Profile/config bundle sync `00001KVZQHPNY` is accepted/inprogress. Web Console will likely touch worker creation/profile selection and Backend API surfaces; start after bundle sync branch is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-26T06:33:06Z"}
{"id":"orch-plan-20260626-074131-5","ticket_id":"00001KVZ9JGK0","kind":"accepted_plan","note":"Dependencies are done: embedded Runtime registry `00001KVZSGT0Q`, WebSocket observation proxy `00001KVZKSTJT`, and config bundle sync `00001KVZQHPNY`. No active inprogress remains.","accepted_plan":{"summary":"Backend internal Runtime 上の toolsなし Companion Worker と Web Console MVP を追加する。Backend API で status/transcript/message send を提供し、Web UI で message round-trip を表示する。raw provider credential/socket/session/runtime path は Browser に出さず、full TUI parity/tool UI/FS-shell authority は扱わない。","branch":"work/00001KVZ9JGK0-web-console-mvp","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZ9JGK0-web-console-mvp","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/workspace-server`, `web/workspace`, `resources/prompts` と必要最小 Cargo/package files の write scope を委譲する。reviewer Worker は read-only で authority non-leak、toolsなし Companion、prompt resource boundary、stream/transcript semantics、backend API/UI tests を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T07:41:31Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Backend内蔵Companion RuntimeとWeb Console MVP' title: 'Backend内蔵Companion RuntimeとWeb Console MVP'
state: 'queued' state: 'done'
created_at: '2026-06-25T11:45:17Z' created_at: '2026-06-25T11:45:17Z'
updated_at: '2026-06-25T20:34:27Z' updated_at: '2026-06-26T08:09:07Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T20:34:27Z' queued_at: '2026-06-25T20:34:27Z'

View File

@ -110,4 +110,306 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-25T20:36:54Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Dashboard Queue 後に Ticket / relations / workspace state を確認した。
- 本 Ticket は Web Console MVP であり、WebSocket/event-stream transport/proxy `00001KVZKSTJT` と Backend embedded Runtime connection `00001KVZSGT0Q` を前提にする。
- `00001KVZKSTJT` は queued/blocked、`00001KVZSGT0Q` も Backend Registry foundation chain 待ち。Web Console を先に始めると response delivery / stream semantics を UI/API 側で先取りして固定するため開始しない。
Evidence checked:
- Ticket body: Companion Runtime/Web Console MVP、message API、transcript/stream response choice、Safety/authority。
- Relations: `depends_on -> 00001KVZKSTJT``depends_on -> 00001KVZSGT0Q`
- Orchestration plan: blocker record `orch-plan-20260625-203613-1` を追加。
Next action:
- 本 Ticket は queued のまま待機。
- `00001KVZKSTJT``00001KVZSGT0Q` が done になった後、Web Console MVP の acceptance criteria を再確認して routing する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T07:41:53Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Dependencies are done: `00001KVZSGT0Q` embedded Runtime connection、`00001KVZKSTJT` WebSocket observation proxy、`00001KVZQHPNY` config bundle sync。
- Current `inprogress` is 0. Web Console MVP can now build on stable Backend internal Runtime / Worker create / transcript / WS/proxy foundations.
- Ticket body has concrete backend API, Web UI, safety/authority, Non-goals, and validation requirements.
Evidence checked:
- Ticket body: Backend internal Companion runtime, conversation/transcript model, Web API, Web Console UI, Runtime/LLM integration, Safety/authority, acceptance criteria。
- Relations: depends_on `00001KVZSGT0Q` and `00001KVZKSTJT`, both done.
- Orchestration plan: accepted plan `orch-plan-20260626-074131-5` recorded.
- Workspace state: orchestration worktree clean; no spawned child Workers currently active.
IntentPacket:
Intent:
- Backend internal Runtime 上に toolsなし Companion Worker を作成・保持し、Web frontend から status/transcript/message send/response display ができる Console MVP を実装する。
Binding decisions / invariants:
- Companion v0 は workspace filesystem / shell / git / Ticket mutation authority を持たない。
- Browser は raw provider credential、socket path、session path、runtime file path、Runtime direct endpoint/token を扱わない。
- Backend API / WS/projection を authority とし、local session file / Pod socket を直接読まない。
- Prompt prose は Rust 直書きではなく `resources/prompts` など prompt resource boundary に置く。
- v0 は full TUI parity / tool call UI / file viewer / diff viewer / thinking block grouping / multi Worker attach を実装しない。
- Long-running request 中の追加 message は single-flight / busy reject でよい。
Requirements / acceptance criteria:
- Backend internal runtime 上に Companion Worker が存在し、runtime/worker API から確認できる。
- Web frontend から Companion status / transcript projection を取得できる。
- Web Console UI から user message を送信できる。
- Companion が LLM response を生成し、Web UI に表示される、または v0実装上の provider-less/mock boundary が明確で reviewer が確認できる。
- v0 Companion は filesystem / shell / git / Ticket mutation tools を持たない。
- Provider busy/error/timeout/cancelled が typed error/UI state で扱われる。
- Focused backend/frontend tests or manual validation notes are present.
Implementation latitude:
- Request/response completion vs existing WS/projection usageの具体方式は Coder が既存 foundation に合わせて選べる。ただし `00001KVZKSTJT` の Backend-owned observation/proxy境界を壊さない。
- v0 LLM/provider integration が重すぎる場合は、Backend internal Runtimeの現在能力内で最小 toolsなし conversational round-trip を成立させ、未実装 provider execution boundaryを明確に報告すること。
- UI route/layout/component naming は existing `web/workspace` style に合わせる。
Escalate if:
- Filesystem/shell/git/Ticket mutation authority が必要になる。
- Browser に Runtime/provider credentials や raw path を渡す必要が出る。
- Full TUI console parity or tool-call UI が必要になる。
- Existing worker-runtime cannot generate a real LLM response without broad worker/engine integration and acceptance criteria cannot be satisfied honestly.
Validation:
- `cargo fmt --all`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task build`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Authority leakage to Browser or Companion Worker。
- Hidden context injection instead of normal conversation history。
- Prompt prose embedded in Rust instead of prompt resource。
- UI claiming live LLM behavior if implementation is actually mock/provider-less。
- Over-scoping into full TUI parity/tool UI。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T07:42:06Z from: queued to: inprogress reason: routing_accepted_after_backend_runtime_foundation_done field: state -->
## State changed
Routing decision: implementation_ready。
Embedded Runtime connection、WebSocket observation proxy、Profile/config bundle sync は done。Ticket body / relations / workspace state / accepted plan を確認し、Backend internal Companion Web Console MVP は unblocked と判断した。accepted plan と IntentPacket は thread / orchestration plan に記録済み。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T07:43:09Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KVZ9JGK0-web-console-mvp`
- Branch: `work/00001KVZ9JGK0-web-console-mvp`
- Coder Worker spawned: `yoi-coder-00001KVZ9JGK0-web-console`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/workspace-server/**`, `web/workspace/**`, `resources/prompts/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Queued capacity note:
- `00001KW04A8K6` remains queued while Web Console MVP is inprogress because both are UI/control-surface consumers of the new Runtime APIs; TUI migration should start after this branch is reviewed/merged/done.
---
<!-- event: implementation_report author: yoi-coder-00001KVZ9JGK0-web-console at: 2026-06-26T08:02:43Z -->
## Implementation report
Implementation report:
実装完了。指定 worktree / branch 上で commit 済み。
Commit:
- `f3ad9c96b3fd48b5afce2a46b9c46efc5dce16f6 feat: add workspace companion console MVP`
変更概要:
### Backend
変更ファイル:
- `crates/workspace-server/src/companion.rs`
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/src/server.rs`
- `resources/prompts/worker/web_companion_providerless.md`
実装内容:
- Backend 内部の tools-less Companion Console 境界を追加。
- `WorkspaceApi` 初期化時に `embedded-worker-runtime` 上へ `workspace_companion` Worker を spawn。
- runtime / worker API の `/api/workers` から visible。
- v0 capabilities は input / bounded transcript projection のみで、filesystem / shell / git / Ticket mutation authority は持たせていない。
- Companion 専用 API を追加:
- `GET /api/companion/status`
- `GET /api/companion/transcript?start=&limit=`
- `POST /api/companion/messages`
- `POST /api/companion/cancel`
- status / transcript / message response は typed state を返す:
- `ready`, `busy`, `error`, `timeout`, `cancelled`, `accepted`, `rejected`
- Browser へは Backend API projection のみ返し、raw Worker socket/session/runtime path や provider credentials は返さない設計。
- 実 LLM/provider 実行は既存 runtime foundation へ狭く接続できなかったため、fake せず provider-less/conversation boundary として実装。
- user message は backend-internal Worker へ input として記録。
- UI transcript には resource-defined assistant boundary response を返す。
- この制限は API diagnostics / UI transport description に明示。
- prompt / assistant boundary prose は Rust hard-code ではなく `resources/prompts/worker/web_companion_providerless.md` に配置。
### Web UI
変更ファイル:
- `web/workspace/src/routes/console/+page.svelte`
- `web/workspace/src/lib/workspace-sidebar/CompanionNavSection.svelte`
- `web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte`
- `web/workspace/src/lib/workspace-sidebar/types.ts`
- `web/workspace/src/app.css`
実装内容:
- `/console` route を追加。
- Sidebar に `Companion Console` 導線を追加。
- Console UI:
- Companion status 表示
- transport / limitation 表示
- bounded transcript 表示
- user message composer
- Send / Refresh / Cancel UI
- busy / error / timeout / cancelled / rejected state 表示
- diagnostics 表示
Validation:
- `cargo fmt --all`: success
- `cargo test -p yoi-workspace-server`: success36 passed
- `cargo check -p yoi`: success
- `cd web/workspace && deno task check`: success0 errors / 0 warnings
- `cd web/workspace && deno task build`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- 実 LLM 応答は未接続。今回の実装は intentionally provider-less boundary であり、assistant response は resource-defined limitation response。
- 既存 worker-runtime へ安全に provider-backed completion を流すには、Worker engine integration / lifecycle / streaming boundary の追加設計が必要そう。
- transcript は v0 in-memory projection。server restart で Companion UI transcript は消える。
- Backend runtime 側には user input のみ記録され、assistant boundary response は Companion projection 側。
- busy は single-flight typed state として用意しているが、provider-less response は同期即時完了のため見える場面は限定的。
- UI は Console MVP に限定。tool call UI / thinking grouping / diff/file viewer / multi Worker attach は非対応。
---
<!-- event: review author: yoi-reviewer-00001KVZ9JGK0-web-console at: 2026-06-26T08:07:24Z status: approve -->
## Review: approve
Review result: approve
確認結果として、Ticket 意図と binding invariants を満たしていると判断する。
確認内容:
- Provider-less 境界は十分に正直。実 LLM 応答ではなく、`resources/prompts/worker/web_companion_providerless.md` の文面を返す実装で、UI/API 側も limitation / diagnostic / `backend_providerless_boundary` として表示している。
- Companion Worker は backend 内部の embedded runtime 上に作られ、runtime/worker API から見える構成。
- Companion v0 に filesystem / shell / git / Ticket mutation authority を付与している箇所は見当たらない。workspace/mount refs なし、conversation-focused の内部 Worker として扱われている。
- Browser に provider credential、socket path、session path、runtime file path、direct runtime endpoint/token、local session file path を渡す新規漏洩は見当たらない。
- `/api/companion/status`、`/transcript`、`/messages`、`/cancel` と Web `/console` は status / transcript / send / cancelled/error/busy 系状態を typed projection として扱っている。
- Prompt prose は Rust hard-code ではなく resource boundary に置かれている。
- UI は full TUI parity、tool/file/diff viewer、thinking grouping、multi attach へ広がっておらず、MVP 範囲に収まっている。
- Backend の focused tests と、報告済みの `cargo test/check`、frontend `deno task check/build`、`git diff --check`、`nix build` はこの変更範囲には十分。
補足リスク:
- provider-less v0 では backend Worker transcript は主に user message 記録で、assistant limitation response は Companion projection 側に出る設計。現状は source/diagnostic 付きなので許容するが、実 LLM 接続時は Worker transcript との永続的一貫性を再検討した方がよい。
- busy reject は型として存在するが、将来の長時間 LLM 実行時には即時 reject/timeout 挙動を改めて検証する必要がある。
検証:
- read-only の静的確認と `git diff --check` 相当の確認。テスト/ビルドはファイル生成を避けるため再実行せず、報告内容と実装・テスト追加を照合した。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T08:08:06Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KVZ9JGK0-web-console-mvp` を orchestration branch に merge 済み。
- Merge commit: `bf834e83 merge: 00001KVZ9JGK0 web console mvp`
Included implementation commit:
- `f3ad9c96b3fd48b5afce2a46b9c46efc5dce16f6 feat: add workspace companion console MVP`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success36 tests passed
- `cargo check -p yoi`: success
- `cd web/workspace && deno task check`: success0 errors / 0 warnings
- `cd web/workspace && deno task build`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。provider-less boundary honesty、authority non-leak、prompt resource boundary、typed UI/API state、MVP scope に blocker なし。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T08:08:16Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Backend internal Companion Runtime / Web Console MVP の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `bf834e83 merge: 00001KVZ9JGK0 web console mvp`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success36 tests passed
- `cargo check -p yoi`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- Backend internal tools-less Companion Worker と provider-less Web Console MVP を追加。
- Full TUI parity / tool call UI / file/diff viewer / thinking grouping / multi Worker attach / real provider-backed Companion execution は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T08:09:07Z -->
## Implementation report
Cleanup report:
- Child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KVZ9JGK0-web-console`
- `yoi-reviewer-00001KVZ9JGK0-web-console`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZ9JGK0-web-console-mvp`
- Child implementation branch removed:
- `work/00001KVZ9JGK0-web-console-mvp`
Operational note:
- StopPod was executed sequentially, not in parallel.
--- ---

View File

@ -2,7 +2,7 @@
title: 'pod crateをworker crateへ改名する' title: 'pod crateをworker crateへ改名する'
state: 'closed' state: 'closed'
created_at: '2026-06-25T13:42:37Z' created_at: '2026-06-25T13:42:37Z'
updated_at: '2026-06-25T19:40:49Z' updated_at: '2026-06-26T03:09:01Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T14:13:35Z' queued_at: '2026-06-25T14:13:35Z'

View File

@ -784,4 +784,23 @@ Scope to check and update where the meaning is the new execution-unit Worker rat
Allowed remaining `pod` wording must be explicitly intentional legacy/internal context, such as historical reports, old compatibility notes, or on-disk/socket/session compatibility details that are being kept temporarily. The implementation should provide grep evidence for remaining active `pod`/`Pod` references and categorize them as replaced, legacy/internal, or historical. Allowed remaining `pod` wording must be explicitly intentional legacy/internal context, such as historical reports, old compatibility notes, or on-disk/socket/session compatibility details that are being kept temporarily. The implementation should provide grep evidence for remaining active `pod`/`Pod` references and categorize them as replaced, legacy/internal, or historical.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T03:09:01Z -->
## Implementation report
Late cleanup report:
- Stale child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KVZG9BMS-worker-rename`
- `yoi-reviewer-00001KVZG9BMS-worker-rename`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZG9BMS-worker-crate-rename`
- Child implementation branch removed:
- `work/00001KVZG9BMS-worker-crate-rename`
Operational note:
- User noted StopPod should be run one-by-one rather than through parallel multi-tool execution; future cleanup will follow that.
--- ---

View File

@ -2,7 +2,7 @@
title: 'worker-runtimeにFS永続化featureを追加する' title: 'worker-runtimeにFS永続化featureを追加する'
state: 'done' state: 'done'
created_at: '2026-06-25T14:44:02Z' created_at: '2026-06-25T14:44:02Z'
updated_at: '2026-06-25T19:49:00Z' updated_at: '2026-06-26T03:09:01Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T16:39:26Z' queued_at: '2026-06-25T16:39:26Z'

View File

@ -316,3 +316,20 @@ Scope:
- REST command server / Backend integration / legacy migration / SQLite store は Non-goals として未実装。 - REST command server / Backend integration / legacy migration / SQLite store は Non-goals として未実装。
--- ---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T03:09:01Z -->
## Implementation report
Cleanup report:
- FS store Coder/Reviewer child Workers were no longer present in the spawned-child registry at cleanup time, so no StopPod action was available for them.
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZKST83-worker-runtime-fs-store`
- Child implementation branch removed:
- `work/00001KVZKST83-worker-runtime-fs-store`
Operational note:
- User noted StopPod should be run one-by-one rather than through parallel multi-tool execution; future cleanup will follow that.
---

View File

@ -1,2 +1,3 @@
{"id":"orch-plan-20260625-164410-1","ticket_id":"00001KVZKSTE2","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. REST command server depends on worker-runtime core `00001KVZBCQH4`, which is currently inprogress and under review. Do not start HTTP/server implementation until core API is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-25T16:44:10Z"} {"id":"orch-plan-20260625-164410-1","ticket_id":"00001KVZKSTE2","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. REST command server depends on worker-runtime core `00001KVZBCQH4`, which is currently inprogress and under review. Do not start HTTP/server implementation until core API is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-25T16:44:10Z"}
{"id":"orch-plan-20260625-165601-2","ticket_id":"00001KVZKSTE2","kind":"waiting_capacity_note","note":"Core dependency is now done, but this REST/http-server Ticket is left queued in this acceptance pass because FS store and Backend Registry foundation were accepted first. `http-server` is likely to modify `crates/worker-runtime` feature/dependency/package surfaces and conflict with FS store work; start after FS store branch stabilizes or Orchestrator explicitly serializes merge conflict handling.","author":"yoi-orchestrator","at":"2026-06-25T16:56:01Z"} {"id":"orch-plan-20260625-165601-2","ticket_id":"00001KVZKSTE2","kind":"waiting_capacity_note","note":"Core dependency is now done, but this REST/http-server Ticket is left queued in this acceptance pass because FS store and Backend Registry foundation were accepted first. `http-server` is likely to modify `crates/worker-runtime` feature/dependency/package surfaces and conflict with FS store work; start after FS store branch stabilizes or Orchestrator explicitly serializes merge conflict handling.","author":"yoi-orchestrator","at":"2026-06-25T16:56:01Z"}
{"id":"orch-plan-20260625-203533-3","ticket_id":"00001KVZKSTE2","kind":"accepted_plan","note":"Core dependency `00001KVZBCQH4` は done、FS store branch も done/merged 済みで previous waiting-capacity reason は解消。現在の inprogress `00001KVZKSV6C` は workspace-server foundation で主変更面が分離しているため並行受理可能。","accepted_plan":{"summary":"worker-runtime core/FS store done 後の optional `http-server` feature slice。REST command API と最小 process wrapper を worker-runtime に追加し、observation stream / Backend client integration / WebSocket は扱わない。","branch":"work/00001KVZKSTE2-worker-runtime-rest-server","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZKSTE2-worker-runtime-rest-server","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/worker-runtime` と必要な Cargo/package files の write scope を委譲する。reviewer Worker は read-only で feature gating、REST handler delegation、Browser direct access exclusion、typed errors、scope creep absence を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-25T20:35:33Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'worker-runtimeにREST command serverを追加する' title: 'worker-runtimeにREST command serverを追加する'
state: 'queued' state: 'done'
created_at: '2026-06-25T14:44:02Z' created_at: '2026-06-25T14:44:02Z'
updated_at: '2026-06-25T16:56:01Z' updated_at: '2026-06-26T04:20:31Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T16:39:39Z' queued_at: '2026-06-25T16:39:39Z'

View File

@ -59,3 +59,348 @@ Escalate if:
- REST server concerns を core crate に混ぜないと acceptance を満たせないように見える。 - REST server concerns を core crate に混ぜないと acceptance を満たせないように見える。
--- ---
<!-- event: decision author: yoi-orchestrator at: 2026-06-25T20:35:55Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Dashboard Queue された dependent chain を再確認した。
- `00001KVZBCQH4` worker-runtime core は done、`00001KVZKST83` FS store feature も done/merged/validated 済み。以前の dependency/capacity blocker は解消した。
- 本 Ticket は REST command server であり、Non-goals に SSE / WebSocket event stream server が明記されている。WebSocket transport decision は `00001KVZKSTJT` 側で扱い、本 Ticket の implementation latitude には含めない。
- 現在の inprogress `00001KVZKSV6C``crates/workspace-server` foundation、こちらは `crates/worker-runtime` http-server feature が主対象で、conflict risk は bounded。
Evidence checked:
- Ticket body: `http-server` feature、Runtime process wrapper、REST command endpoints、typed JSON/errors、Browser direct Runtime access exclusion、Non-goals。
- Relations: outgoing dependency `00001KVZBCQH4` は done。incoming `00001KVZKSTJT`, `00001KVZSGT14` は後続。
- Orchestration plan: accepted plan `orch-plan-20260625-203533-3` を記録。
- Workspace state: orchestration worktree clean; current active foundation branch is separate surface.
IntentPacket:
Intent:
- `worker-runtime` に optional `http-server` feature と最小 Runtime process REST command API を追加する。
Binding decisions / invariants:
- REST handlers は `Runtime` lib API を呼ぶ wrapper とし、Worker semantics を二重実装しない。
- Browser は Runtime process に直接接続せず、Backend 経由の前提を docs/comments/API comments に残す。
- SSE / WebSocket event stream server、Backend HTTP client integration、dynamic Runtime registration、Web Console、full auth model は実装しない。
- `http-server` disabled 時に core library は HTTP server dependency を強制しない。
- Runtime authority は Runtime/Worker identity。legacy pod/socket/session path を public REST authority として設計しない。
Requirements / acceptance criteria:
- `http-server` feature と binary/process wrapper がある。
- `GET /v1/runtime`, `GET /v1/workers`, `GET /v1/workers/{worker_id}`, `POST /v1/workers`, `POST /v1/workers/{worker_id}/input`, `stop`, `cancel`, `GET transcript` を扱う。
- typed request/response/error shapes を持つ。
- runtime id / bind address / store selection を v0 config として扱える。
- minimal local token placeholder は可。ただし Browser に Runtime credential を渡す前提にしない。
Implementation latitude:
- HTTP framework/dependency、binary/module split、test helper、typed response shapes の詳細は Coder が選べる。
- FS store 使用は既存 feature を使う範囲まで。新しい persistence design は不要。
Escalate if:
- WebSocket/SSE observation 実装が必要になりそうな場合。
- Backend integration や dynamic runtime registration を同時に実装しないと REST command server が成立しない場合。
- Core Runtime API の大幅変更が必要になる場合。
Validation:
- `cargo fmt --all`
- `cargo test -p worker-runtime --no-default-features`
- `cargo test -p worker-runtime --features http-server`
- 必要に応じて `cargo test -p worker-runtime --features fs-store,http-server`
- `cargo check -p yoi`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Feature gating/dependency leakage。
- REST handler が Runtime semantics を複製すること。
- Browser direct Runtime access や credential leakage。
- WebSocket/SSE scope creep。
- package.nix cargoHash / lock consistency。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-25T20:36:02Z from: queued to: inprogress reason: routing_accepted_after_core_and_fs_store_done field: state -->
## State changed
Routing decision: implementation_ready。
Dependency `00001KVZBCQH4` worker-runtime core は done。以前の waiting-capacity reason だった FS store branch も done/merged/validated 済み。Ticket body / relations / workspace state / accepted plan を確認し、REST command server slice は unblocked と判断した。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-25T20:38:01Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KVZKSTE2-worker-runtime-rest-server`
- Branch: `work/00001KVZKSTE2-worker-runtime-rest-server`
- Coder Worker spawned: `yoi-coder-00001KVZKSTE2-rest-server`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/worker-runtime/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix`.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Dependent queued Tickets:
- `00001KVZKSTJT`, `00001KVZSGT14`, `00001KW04A8K6`, and `00001KVZ9JGK0` remain queued with blocker notes until their prerequisite chain is done.
---
<!-- event: implementation_report author: yoi-coder-00001KVZKSTE2-rest-server at: 2026-06-26T03:10:50Z -->
## Implementation report
Implementation report:
実装完了。マージ・Ticket 更新・cleanup はしていない。
Commit:
- `f43a6b84011024b68c03fd5b4211ab427614683b feat: add worker runtime REST server`
変更ファイル:
- `crates/worker-runtime/Cargo.toml`
- optional feature `http-server` を追加。
- `axum`, `tokio`, `tower`, `serde_json` を feature-gated dependency として追加。
- `crates/worker-runtime/src/lib.rs`
- `#[cfg(feature = "http-server")] pub mod http_server;` を追加。
- core は HTTP 非依存で、REST adapter は optional である旨を module docs に追記。
- `crates/worker-runtime/src/http_server.rs`
- Runtime REST process adapter を新規追加。
- `Cargo.lock`
- `worker-runtime` optional dependency 反映。
- `package.nix`
- cargo vendor hash 更新。
追加 API / endpoints:
- `worker_runtime::http_server` module`http-server` feature 有効時)。
- `RuntimeHttpServerConfig`: `bind_addr`, `runtime_id`, `display_name`, `limits`, `store`, `local_token`
- `RuntimeHttpStoreSelection`: `Memory`; `Fs { root }``fs-store` feature 併用時のみ。
- `RuntimeHttpServer::bind(...)`, `RuntimeHttpServer::serve(...)`, `serve_configured_runtime_http(...)`, `serve_runtime_http(...)`, `runtime_http_router(...)`
- REST endpoints:
- `GET /v1/runtime`
- `GET /v1/workers`
- `GET /v1/workers/{worker_id}`
- `POST /v1/workers`
- `POST /v1/workers/{worker_id}/input`
- `POST /v1/workers/{worker_id}/stop`
- `POST /v1/workers/{worker_id}/cancel`
- `GET /v1/workers/{worker_id}/transcript`
Boundary notes:
- 各 handler は `Runtime` lib API に委譲し、Worker semantics は複製していない。
- Worker authority は server 側 Runtime id + path の `worker_id` から `WorkerRef` を組み立てる形。
- module docs / API comments に「Browser は Runtime process へ直接接続しない。Backend/proxy が intended client」という境界を明記。
- SSE / WebSocket / Backend integration / dynamic registration / Web Console / full auth model は未実装。
- `local_token` は backend-to-runtime 用の最小 bearer token placeholder のみ。
Validation:
- `cargo fmt --all`: success
- `cargo test -p worker-runtime --no-default-features`: success8 passed
- `cargo test -p worker-runtime --features http-server`: success11 passed
- `cargo test -p worker-runtime --features fs-store,http-server`: success13 passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Notes:
- `nix build` は初回 `cargoHash` 不一致で失敗。hash 更新後に再実行して成功。
Residual risks / review focus:
- REST response shapes は v0 として明示型を追加したが、外部 API として固定する前に Backend 側の envelope naming / field naming は確認余地あり。
- `stop` / `cancel` は空 body も許容し、body がある場合は `{ "reason": ... }` を受ける。Backend 側で厳密 JSON body 必須にしたい場合は調整可能。
- `local_token` は placeholder であり、Browser-facing credential model ではない。この境界が崩れていないか review focus。
- `tower` は router unit test helper 用にも使うが、`http-server` feature 配下の optional dependency。core / no-default build には入らない。
---
<!-- event: review author: yoi-reviewer-00001KVZKSTE2-rest-server at: 2026-06-26T03:14:43Z status: request_changes -->
## Review: request changes
Review result: request_changes
ブロッカー:
- Ticket 本文 / route packet では `worker-runtime/main.rs` または binary/process wrapper が必要。
- Ticket item: Runtime process wrapper / binary starts。
- Routing packet: “http-server feature と binary/process wrapper がある”。
- しかし実装は `crates/worker-runtime` の lib target のみ。
- `cargo read-manifest --manifest-path crates/worker-runtime/Cargo.toml` で target は `lib` のみ。
- `crates/worker-runtime/src/main.rs``[[bin]]` は存在しない。
- `RuntimeHttpServer::bind/serve` は library helper としては良いが、Runtime process binary が起動して REST endpoints を公開する受け入れ条件は未達。
確認できた良い点:
- `http-server` feature は optional で追加されており、`cargo tree -p worker-runtime --no-default-features` では HTTP deps 漏れは見えない。
- REST router は要求 endpoint を定義している。
- handlers は `Runtime` API に委譲しており、Worker semantics の大きな重複は見えない。
- Browser は Backend 経由で Runtime に直接接続しない旨の module/API comment がある。
- Runtime/Worker identity を使い、legacy pod/socket/session path を authority にしていない。
- typed response/error shape と RuntimeError の HTTP status mapping はある。
- SSE/WebSocket scope creep は見えない。
- `package.nix``cargoHash` 更新は commit に含まれている。
検証は read-only inspection 中心で、テスト/ビルドは再実行せず、報告済み結果を参照した。
---
<!-- event: implementation_report author: yoi-coder-00001KVZKSTE2-rest-server at: 2026-06-26T03:21:26Z -->
## Implementation report
Follow-up implementation report after review request_changes:
追加 commit:
- `d0db32fa6acb6efb5269de9793a63de92ef9eb4a fix: add worker runtime REST process binary`
修正内容:
- `crates/worker-runtime/Cargo.toml`
- `[[bin]]` target を追加。
- binary name: `worker-runtime-rest-server`
- path: `src/main.rs`
- `required-features = ["http-server"]`
- `crates/worker-runtime/src/main.rs`
- minimal Runtime REST process wrapper を追加。
Binary / process wrapper 概要:
- `http-server` feature 有効時に `worker-runtime-rest-server` binary target が出る。
- v0 config:
- `--bind <ADDR>`
- `--runtime-id <ID>`
- `--display-name <NAME>`
- `--store <memory|fs>`
- `--fs-root <PATH>``fs-store` feature 併用時のみ有効)
- `--local-token <TOKEN>`
- `--local-token-env <ENV>`
- `--max-transcript-projection-items <N>`
- `--max-event-batch-items <N>`
Boundary notes:
- binary は `http-server` required feature 付きなので、no-default build では HTTP deps / binary compile を強制しない。
- process wrapper は既存の `RuntimeHttpServer::bind/serve` を使い、REST handlers は従来通り Runtime API に委譲する。
- help / startup message に「trusted backend/proxy 向けであり Browser は Runtime process に直接接続しない」境界を明記。
- SSE / WebSocket / Backend integration / dynamic registration / full auth model は追加していない。
Manifest 確認:
- `cargo read-manifest --manifest-path crates/worker-runtime/Cargo.toml | jq '.targets'``lib` に加えて `bin` target `worker-runtime-rest-server`、`required-features = ["http-server"]` を確認。
Validation:
- `cargo fmt --all`: success
- `cargo test -p worker-runtime --no-default-features`: success8 passed
- `cargo test -p worker-runtime --features http-server`: successlib 11 passed, bin `src/main.rs` 3 passed
- `cargo test -p worker-runtime --features fs-store,http-server`: successlib 13 passed, bin `src/main.rs` 3 passed
- `cargo check -p worker-runtime --features http-server --bins`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- CLI は intentionally minimal。`clap` 等は追加せず手書き parser。
- `--local-token` は placeholder であり、Browser-facing credential model ではない。必要なら後続 Ticket で Backend 側 auth / proxy 境界として設計する前提。
- `fs` store は `fs-store` feature 併用時のみ有効で、`http-server` 単独 build では明示エラー。
---
<!-- event: review author: yoi-reviewer-00001KVZKSTE2-rest-server at: 2026-06-26T03:23:55Z status: approve -->
## Review: approve
Review result: approve
確認結果:
- 以前の blockerRuntime process wrapper / binary target 欠落)は解消済み。
- `crates/worker-runtime/Cargo.toml``[[bin]]` が追加され、`worker-runtime-rest-server` / `src/main.rs` / `required-features = ["http-server"]` になっている。
- `http-server` feature 必須の binary target なので、no-default/core library 側に HTTP server binary/deps を強制しない形。
- `src/main.rs``RuntimeHttpServer::bind(config)``server.serve().await` を呼ぶ薄い process wrapper で、REST semantics を再実装していない。
- v0 config は bind addr / runtime id / display name / memory/fs store selection / local token/env token / transcript/event limits を扱っており、Ticket 範囲として妥当。
- help・module comment・startup log で「Browser は Runtime process に直接接続せず trusted backend/proxy 経由」の境界が明記されている。
- SSE / WebSocket / Backend integration / dynamic registration / full auth model の追加は見当たらない。
実施した検証は read-only inspection と `cargo read-manifest` / `rg` / `git` による確認。報告済み cargo/nix validation は再実行していない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T03:27:12Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KVZKSTE2-worker-runtime-rest-server` を orchestration branch に merge 済み。
- Merge commit: `660b07e8 merge: 00001KVZKSTE2 worker runtime rest server`
Included implementation commits:
- `f43a6b84011024b68c03fd5b4211ab427614683b feat: add worker runtime REST server`
- `d0db32fa6acb6efb5269de9793a63de92ef9eb4a fix: add worker runtime REST process binary`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --no-default-features`: success8 tests passed
- `cargo test -p worker-runtime --features http-server`: successlib 11 tests + bin 3 tests passed
- `cargo test -p worker-runtime --features fs-store,http-server`: successlib 13 tests + bin 3 tests passed
- `cargo check -p worker-runtime --features http-server --bins`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。前回 blocker だった Runtime process wrapper / binary target 欠落は解消済み。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T03:27:22Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
worker-runtime REST command server の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `660b07e8 merge: 00001KVZKSTE2 worker runtime rest server`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --no-default-features`: success8 tests passed
- `cargo test -p worker-runtime --features http-server`: successlib 11 tests + bin 3 tests passed
- `cargo test -p worker-runtime --features fs-store,http-server`: successlib 13 tests + bin 3 tests passed
- `cargo check -p worker-runtime --features http-server --bins`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- optional `http-server` feature、REST command API、minimal `worker-runtime-rest-server` process binary を追加。
- SSE / WebSocket / Backend integration / dynamic registration / Web Console / full auth model は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T04:20:31Z -->
## Implementation report
Cleanup report:
- Child Workers cleanup:
- `yoi-coder-00001KVZKSTE2-rest-server` was stopped one-by-one and scope reclaimed.
- `yoi-reviewer-00001KVZKSTE2-rest-server` StopPod retry reported `no spawned pod named`; `ListPods` then showed no spawned children remaining, so it was already gone from the spawned-child registry.
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZKSTE2-worker-runtime-rest-server`
- Child implementation branch removed:
- `work/00001KVZKSTE2-worker-runtime-rest-server`
Operational note:
- StopPod will continue to be executed one-by-one rather than through parallel multi-tool execution.
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260625-203613-1","ticket_id":"00001KVZKSTJT","kind":"blocked_by","related_ticket":"00001KVZKSTE2","note":"Queue routing checked. WebSocket observation proxy depends on REST command server `00001KVZKSTE2`, which has just been accepted and is now inprogress. Leave this Ticket queued until REST command API/process wrapper is reviewed/merged/done, so WS/proxy semantics build on stable command surface.","author":"yoi-orchestrator","at":"2026-06-25T20:36:13Z"}
{"id":"orch-plan-20260626-042150-2","ticket_id":"00001KVZKSTJT","kind":"accepted_plan","note":"Dependency REST command server `00001KVZKSTE2` は done。ユーザー指摘後に transport decision Ticket として再queuedされたため、WS/proxy semantics を本 Ticket で固定する。","accepted_plan":{"summary":"REST command server done 後の WebSocket observation proxy slice。Runtime process 側の worker-scoped observation stream と Backend proxy/client-facing stream boundary を実装する。REST command semantics や Web Console/TUI migration は扱わない。","branch":"work/00001KVZKSTJT-websocket-observation-proxy","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZKSTJT-websocket-observation-proxy","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/worker-runtime` / `crates/workspace-server` と必要な Cargo/package files の write scope を委譲する。reviewer Worker は read-only で Runtime→Backend→Client proxy boundary、cursor/backlog semantics、Browser direct Runtime access exclusion、feature gating、REST/WS scope separation を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T04:21:50Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Runtime/Backend WebSocket observation proxyを実装する' title: 'Runtime/Backend WebSocket observation proxyを実装する'
state: 'queued' state: 'done'
created_at: '2026-06-25T14:44:02Z' created_at: '2026-06-25T14:44:02Z'
updated_at: '2026-06-25T20:34:20Z' updated_at: '2026-06-26T05:16:51Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T20:34:20Z' queued_at: '2026-06-25T20:34:20Z'

View File

@ -150,4 +150,407 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-25T20:36:24Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Dashboard Queue 後に Ticket / relations / workspace state を確認した。
- 本 Ticket は `00001KVZKSTE2` REST command server に depends_on。`00001KVZKSTE2` は本 routing pass で accepted され `inprogress` になった。
- WS observation proxy は Runtime process server surface と Backend proxy/client-facing stream を扱うため、REST command API/process wrapper の形が確定してから開始する。
Evidence checked:
- Ticket body: `Runtime -> Backend -> Client` WebSocket observation proxy、Runtime worker-scoped WS、Backend Runtime WS client、Client-facing WS、cursor/backlog/permission seam。
- Relations: outgoing `depends_on -> 00001KVZKSTE2`; incoming dependent Tickets include Web Console MVP, remote Runtime process, TUI migration。
- Orchestration plan: blocker record `orch-plan-20260625-203613-1` を追加。
Next action:
- 本 Ticket は queued のまま待機。
- `00001KVZKSTE2` が review/merge/validation/done になった後に再 routing する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T04:22:18Z -->
## Decision
Routing decision: implementation_ready
Reason:
- `00001KVZKSTE2` REST command server は done。前回 blockerREST process wrapper/API surface 未確定)は解消済み。
- 本 Ticket はユーザー指摘後に `Runtime/Backend WebSocket observation proxy` として設計判断を反映し再queuedされた。Ticket thread には、Runtime WS は `protocol::Event` を observation payload として流し、Browser/future TUI は Runtime へ直接接続せず Backend-owned projection/proxy を見る、という binding decision が記録済み。
- queued/inprogress 再確認時点で inprogress は 0 件。後続 remote/TUI/Web Console Tickets は本 Ticket に依存しているため、本 Ticket を次に受理する。
Evidence checked:
- Ticket body: `Runtime -> Backend -> Client` WebSocket observation proxy、Runtime worker-scoped WS、Backend Runtime WS client、Client-facing WS、cursor/backlog/permission seam、Non-goals。
- Thread decisions: `protocol::Event` を payload authority とする、Runtime WS は command/mutation tunnel にしない、Backend projection/proxy seam を作る、full auth/redaction policy は後続。
- Relations: outgoing dependencies `00001KVZBCQH4` core と `00001KVZKSTE2` REST server は done。incoming remote/Web Console/TUI Tickets は後続。
- Orchestration plan: accepted plan `orch-plan-20260626-042150-2` を記録。
- Workspace state: orchestration worktree clean; no inprogress Ticket.
IntentPacket:
Intent:
- Runtime process の worker-scoped WebSocket observation stream と、Backend-owned client-facing WebSocket proxy boundary を実装する。
Binding decisions / invariants:
- Runtime WS は Backend-facing internal observation API。Browser/future TUI は Runtime WS に直接接続しない。
- Payload authority は `crates/protocol``protocol::Event`。Runtime WS 独自の parallel output model や variant allowlist/subset を作らない。
- Runtime WS は command/mutation/user input を受け付けず、`protocol::Method` tunnel を作らない。
- Backend client-facing WS は Backend-owned opaque cursor/envelope/diagnostic を持ち、Runtime endpoint/credential/socket/session path を Client に露出しない。
- v0 は worker-scoped stream。runtime-wide stream、full auth/permission/redaction policy、Web Console UI、TUI migration、remote process lifecycle/discovery は Non-goals。
- REST command semantics は既存 `http-server` implementation に委譲し、この Ticket で再実装しない。
Requirements / acceptance criteria:
- `worker-runtime` に optional `ws-server` feature がある。
- Feature disabled でも core compile が通る。
- Runtime process exposes `GET /v1/workers/{worker_id}/events/ws?cursor=...` style worker-scoped observation endpoint。
- Runtime WS envelope includes Runtime-local opaque cursor/event id, worker id, and `protocol::Event` payload。
- Connect sends initial `protocol::Event::Snapshot` projection, then forwards Worker event bus `protocol::Event` payloads。
- Backend Runtime WS client consumes Runtime envelope and preserves `runtime_id + worker_id + runtime_cursor + protocol::Event` internally。
- Backend exposes Client-facing worker observation WS keyed by `runtime_id + worker_id` with Backend-local opaque cursor/envelope。
- Unknown/expired cursor, worker not found, runtime unavailable, upstream disconnect, malformed frame are typed diagnostics/errors。
- Tests cover Runtime WS, Backend upstream client/proxy delivery, cursor resume/duplicate-safe IDs, diagnostics, and worker-scoped filtering.
Implementation latitude:
- Exact Rust module split, WebSocket dependency, envelope structs, test fixtures, and Backend route shape may follow existing workspace-server/worker-runtime style。
- Bounded backlog implementation can be in-memory v0, as long as cursor semantics and diagnostics are explicit.
- Permission seam can be pass-through default with types/hooks for later policy.
Escalate if:
- Implementing this requires full auth/redaction policy, Web Console UI, TUI migration, remote process lifecycle/discovery, or Runtime command channel changes。
- `protocol::Event` cannot be serialized/forwarded without changing protocol crate public semantics。
- Backend Registry/Runtime handle shape from previous Tickets is insufficient and would require a broad redesign.
Validation:
- `cargo fmt --all`
- `cargo test -p worker-runtime --features ws-server`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Accidentally creating a command/mutation tunnel over WS。
- Leaking Runtime endpoint/token/socket/session path to Client-facing WS。
- Creating a second output/event model instead of forwarding `protocol::Event` payload。
- Feature gating/dependency leakage。
- Cursor/backlog/diagnostic semantics being too vague for remote/TUI/Web Console follow-ups。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T04:22:27Z from: queued to: inprogress reason: routing_accepted_after_rest_server_done field: state -->
## State changed
Routing decision: implementation_ready。
Dependency `00001KVZKSTE2` REST command server は done。Ticket body/thread、relations、workspace state、accepted plan を確認し、Runtime/Backend WebSocket observation proxy は unblocked と判断した。accepted plan と IntentPacket は thread / orchestration plan に記録済み。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T04:23:22Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KVZKSTJT-websocket-observation-proxy`
- Branch: `work/00001KVZKSTJT-websocket-observation-proxy`
- Coder Worker spawned: `yoi-coder-00001KVZKSTJT-ws-proxy`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/worker-runtime/**`, `crates/workspace-server/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix`.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Next action:
- Wait for Coder implementation report, then route to reviewer.
---
<!-- event: implementation_report author: yoi-coder-00001KVZKSTJT-ws-proxy at: 2026-06-26T04:56:04Z -->
## Implementation report
Implementation report:
実装完了。Ticket ファイル / project records は変更していない。merge / close / cleanup 前で停止。
Commit:
- `9807accaf08f4322763dd7cd3536baf3dab8d10e feat: add worker observation websocket proxy`
変更概要:
### worker-runtime
変更ファイル:
- `crates/worker-runtime/Cargo.toml`
- `crates/worker-runtime/src/observation.rs`
- `crates/worker-runtime/src/runtime.rs`
- `crates/worker-runtime/src/http_server.rs`
実装内容:
- optional feature `ws-server` を追加。
- `http-server` に加えて `axum/ws`, `protocol`, `futures`, `tokio/sync` を使用。
- feature 無効時の core compile も確認済み。
- Runtime 側に Worker-scoped observation backlog / bus を追加。
- Runtime-local opaque cursor: `wo_<hex sequence>`
- bounded in-memory backlog v0
- `observe_worker_event(worker_ref, protocol::Event)``protocol::Event` payload をそのまま observation bus に投入。
- Runtime HTTP server に worker-scoped WS endpoint を追加。
- `GET /v1/workers/{worker_id}/events/ws?cursor=...`
- Runtime WS frame:
- `RuntimeWorkerEventWsFrame::Event { envelope }`
- `RuntimeWorkerEventWsFrame::Diagnostic { diagnostic }`
- Runtime WS envelope:
- `cursor`
- `event_id`
- `worker_id`
- `payload: protocol::Event`
- 接続時に `protocol::Event::Snapshot` を初期 projection として送信。
- live update は observation bus の `protocol::Event` payload を転送。
- client からの text/binary 等の入力は `runtime.observation_only` diagnostic で終了。
- malformed cursor / unknown-or-expired cursor / worker not found / bus closed / lagged backlog などを typed diagnostic/error 化。
### workspace-server
変更ファイル:
- `crates/workspace-server/Cargo.toml`
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/src/observation.rs`
- `crates/workspace-server/src/server.rs`
実装内容:
- Backend-owned observation proxy module を追加。
- Runtime WS client を追加。
- Runtime WS envelope を decode。
- 内部表現として `runtime_id + worker_id + runtime_cursor + protocol::Event` を保持。
- Backend client-facing WS endpoint を追加。
- `GET /api/runtimes/{runtime_id}/workers/{worker_id}/events/ws?cursor=...`
- Backend-local cursor/envelope を追加。
- Backend cursor: `bo_<hex sequence>`
- Runtime endpoint / credential / socket / session path は client-facing payload に出さない。
- Backend client-facing frame:
- `ClientWorkerEventWsFrame::Event { envelope }`
- `ClientWorkerEventWsFrame::Diagnostic { diagnostic }`
- Backend client-facing envelope:
- `cursor`
- `event_id`
- `runtime_id`
- `worker_id`
- `payload: protocol::Event`
- Backend source config は server 内部設定 `runtime_event_sources` として保持。
- unknown worker/runtime source, malformed/unknown cursor, upstream disconnect, malformed upstream frame, observation-only client input を typed diagnostic/error 化。
### workspace/root/package
変更ファイル:
- `Cargo.toml`
- `Cargo.lock`
- `package.nix`
内容:
- `tokio-tungstenite = "0.29"` を workspace dependency に追加。
- `package.nix``cargoHash` を更新。
Validation:
- `cargo fmt --all`: success
- `cargo check -p worker-runtime`: success
- `cargo test -p worker-runtime --features ws-server`: success14 lib tests + 3 bin tests + doctests
- `cargo test -p yoi-workspace-server`: success22 lib tests + bin/doctests
- `cargo check -p yoi`: success
- `git diff --check`: success
- `git diff --check --cached`: success
- `nix build .#yoi --no-link`: success
Review focus / residual risks:
- v0 backlog は in-memory bounded。process restart / memory eviction 後の cursor は unknown/expired 扱い。
- Runtime Snapshot projection は minimal projection。`protocol::Event::Snapshot` を使うが、entries / in-flight は現 Runtime adapter の範囲で空 projection。
- Backend source discovery は `runtime_event_sources` 内部 config seam。remote lifecycle/discovery や full registry redesign は Non-goals として未実装。
- Auth/redaction/policy は full 実装せず、後続拡張しやすい typed source/diagnostic/proxy boundary に留めた。
- Browser / future TUI には Backend endpoint のみを公開し、Runtime endpoint / credential / socket / session path は envelope に含めていない。
---
<!-- event: review author: yoi-reviewer-00001KVZKSTJT-ws-proxy at: 2026-06-26T05:02:45Z status: request_changes -->
## Review: request changes
Review result: request_changes
読取専用で `9807acc...` の実装を確認した。`protocol::Event` payload の pass-through、Runtime/Backend の envelope 分離、observation-only 入力診断、Client envelope から Runtime endpoint/token/socket/session path を出さない点、`worker-runtime` の `ws-server` cfg 境界は概ね Ticket 意図に沿っている。
ただし、以下は受け入れ条件上の blocker。
1. Backend Runtime WS client の upstream 診断が型を失っている
- `crates/workspace-server/src/observation.rs``RuntimeWsObservationClient::next_event` で、Runtime からの `RuntimeWorkerEventWsFrame::Diagnostic` をすべて `ObservationProxyError::UpstreamDisconnect` に畳み込んでいる。
- そのため Runtime 側の `runtime.cursor_unknown_or_expired` / `runtime.worker_not_found` 等が Client-facing では `backend.upstream_disconnect` になり、Ticket 要件の「worker not found / unknown cursor / expired cursor / upstream disconnect を typed diagnostic として扱う」を満たさない。
- 同様に Runtime WS connect 時の HTTP 404 等も `RuntimeUnavailable` 扱いで、worker-not-found として区別されない。
2. 必須テストカバレッジが不足している
- Ticket 受け入れ条件では、expired/unknown cursor diagnostics、upstream disconnect diagnostics、worker-scoped filtering 等の WS proxy tests が要求されている。
- 現状確認できたテストは Runtime 側の snapshot/live/resume/filter と malformed cursor/observation-only、Backend 側の snapshot/live/resume/malformed cursor/observation-only が中心で、Backend の unknown/expired cursor、Runtime upstream diagnostic の型変換、upstream disconnect、Runtime worker-not-found 経路を検証していない。
上記を修正し、Runtime diagnostic / HTTP failure を Backend の typed diagnostic に適切にマップするテストを追加すれば、設計境界自体は approve に近い状態。
---
<!-- event: implementation_report author: yoi-coder-00001KVZKSTJT-ws-proxy at: 2026-06-26T05:10:58Z -->
## Implementation report
Follow-up implementation report after review request_changes:
Fix commit:
- `8cc9a594f78feef62aa7abd5bbf11b5cd40b4781 fix: preserve runtime websocket diagnostics`
修正内容:
### 1. Runtime WS diagnostic の型を Backend で保持 / 変換
`crates/workspace-server/src/observation.rs`
- `RuntimeWsObservationClient::next_event` が Runtime diagnostic を全部 `backend.upstream_disconnect` に潰していた問題を修正。
- Runtime diagnostic code を Backend typed diagnostic に変換:
- `runtime.worker_not_found``backend.worker_not_found`
- `runtime.cursor_malformed``backend.cursor_malformed`
- `runtime.cursor_unknown_or_expired` / `runtime.cursor_expired``backend.cursor_unknown_or_expired`
- `runtime.upstream_closed` / `runtime.websocket_error``backend.upstream_disconnect`
- `runtime.unavailable``backend.runtime_unavailable`
- `runtime.serialize_failed``backend.malformed_frame`
- `runtime.observation_only``backend.observation_only`
- Runtime WS connect 時の HTTP 404 を `backend.worker_not_found` として扱うように変更。
- 実際の socket close / receive error は引き続き `backend.upstream_disconnect` として区別。
### 2. WS proxy テスト追加
`crates/workspace-server/src/server.rs`
追加 coverage:
- Backend unknown/expired cursor:
- well-formed だが未知の `bo_...` cursor が `backend.cursor_unknown_or_expired` になること。
- Runtime upstream diagnostic mapping:
- Runtime の `runtime.cursor_unknown_or_expired` が client-facing `backend.cursor_unknown_or_expired` に変換されること。
- Runtime worker-not-found / HTTP 404 mapping:
- Runtime WS upgrade が 404 を返すケースを `backend.worker_not_found` として返すこと。
- Upstream disconnect:
- Runtime upstream が WS 接続後に閉じた場合、`backend.upstream_disconnect` として返すこと。
既存の snapshot/live/resume/filter/malformed cursor/observation-only coverage は維持。
Validation:
- `cargo fmt --all`: success
- `cargo check -p worker-runtime`: success
- `cargo test -p worker-runtime --features ws-server`: success14 tests passed + bin/doc tests
- `cargo test -p yoi-workspace-server`: success26 tests passed + bin/doc tests
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- Runtime diagnostic code の Backend 変換は現 Runtime diagnostic set に対する明示 mapping。未知 code は `backend.runtime_unavailable` に安全側で畳む。
- HTTP 404 は worker-not-found として扱う。Runtime 側で route miss と worker miss の HTTP body までは分離していないため、client-facing には endpoint/path/token 等を出さず typed diagnostic のみ返す。
- v0 cursor/backlog は引き続き in-memory bounded。再起動 / eviction 後は unknown/expired cursor diagnostic。
---
<!-- event: review author: yoi-reviewer-00001KVZKSTJT-ws-proxy at: 2026-06-26T05:15:04Z status: approve -->
## Review: approve
Review result: approve
読取専用で follow-up commit `8cc9a594f78feef62aa7abd5bbf11b5cd40b4781` を確認した。前回 blockers は解消済み。
確認した点:
- `RuntimeWsObservationClient::next_event` が Runtime diagnostic を `backend.*` typed diagnostic にマップし、`runtime.cursor_unknown_or_expired` / `runtime.worker_not_found` 等を `backend.upstream_disconnect` に潰さなくなっている。
- 実際の socket close / receive error は引き続き `backend.upstream_disconnect` として区別されている。
- Runtime WS connect の HTTP 404 が `backend.worker_not_found` にマップされている。
- 追加テストで Backend unknown cursor、Runtime cursor diagnostic mapping、Runtime worker-not-found 404 mapping、actual upstream disconnect が検証されている。
- Client-facing envelope は `cursor`, `event_id`, `runtime_id`, `worker_id`, `payload: protocol::Event` のみで、Runtime endpoint/token/socket/session path は含まれていない。
- observation-only 境界を壊す command tunnel / SSE / lifecycle / full auth redesign の scope creep は見当たらない。
検証は read-only inspection のみで、cargo/nix は再実行していない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T05:16:01Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KVZKSTJT-websocket-observation-proxy` を orchestration branch に merge 済み。
- Merge commit: `ae0f0d1d merge: 00001KVZKSTJT websocket observation proxy`
Included implementation commits:
- `9807accaf08f4322763dd7cd3536baf3dab8d10e feat: add worker observation websocket proxy`
- `8cc9a594f78feef62aa7abd5bbf11b5cd40b4781 fix: preserve runtime websocket diagnostics`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo check -p worker-runtime`: success
- `cargo test -p worker-runtime --features ws-server`: success14 lib tests + 3 bin tests + doctests
- `cargo test -p yoi-workspace-server`: success26 tests + bin/doc tests
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。前回 blocker だった Runtime diagnostic の Backend typed diagnostic mapping、HTTP 404 worker-not-found mapping、unknown cursor / upstream disconnect test coverage は解消済み。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T05:16:11Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Runtime/Backend WebSocket observation proxy の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `ae0f0d1d merge: 00001KVZKSTJT websocket observation proxy`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo check -p worker-runtime`: success
- `cargo test -p worker-runtime --features ws-server`: success14 lib tests + 3 bin tests + doctests
- `cargo test -p yoi-workspace-server`: success26 tests + bin/doc tests
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- Runtime process worker-scoped WebSocket observation stream と Backend-owned client-facing WebSocket proxy boundary を追加。
- command tunnel / SSE / remote lifecycle/discovery / full auth-redaction policy / Web Console UI は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T05:16:51Z -->
## Implementation report
Cleanup report:
- Child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KVZKSTJT-ws-proxy`
- `yoi-reviewer-00001KVZKSTJT-ws-proxy`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZKSTJT-websocket-observation-proxy`
- Child implementation branch removed:
- `work/00001KVZKSTJT-websocket-observation-proxy`
Operational note:
- StopPod was executed sequentially, not in parallel.
--- ---

View File

@ -1,8 +1,8 @@
--- ---
title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する' title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する'
state: 'inprogress' state: 'done'
created_at: '2026-06-25T14:44:03Z' created_at: '2026-06-25T14:44:03Z'
updated_at: '2026-06-25T19:37:41Z' updated_at: '2026-06-26T03:09:01Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T16:31:28Z' queued_at: '2026-06-25T16:31:28Z'

View File

@ -263,3 +263,132 @@ Review result: request_changes
- read-only で `git diff --check f6fd7b63^ f6fd7b63` は問題なし。Cargo/Nix 検証は read-only 指示を優先して再実行していない。 - read-only で `git diff --check f6fd7b63^ f6fd7b63` は問題なし。Cargo/Nix 検証は read-only 指示を優先して再実行していない。
--- ---
<!-- event: implementation_report author: yoi-coder-00001KVZKSV6C-runtime-registry at: 2026-06-25T20:38:31Z -->
## Implementation report
Follow-up implementation report after review request_changes:
Commit:
- `d7c4396c fix: scope workspace worker lookup by runtime`
対応内容:
- `RuntimeRegistry::worker``worker_id` 単独 lookup から `runtime_id + worker_id` lookup に変更。
- 先に `runtime_id` を解決。
- runtime が存在しない場合は `UnknownRuntime`
- runtime は存在するが worker が存在しない場合は `UnknownWorker { runtime_id, worker_id }`
- worker_id 衝突時に別 runtime の worker を誤って返さない。
- `RuntimeRegistryError` / workspace-server `Error` に typed error boundary を追加・整理。
- `UnknownRuntime(String)`
- `UnknownWorker { runtime_id, worker_id }`
- server 側の error-to-status mapping 更新。
- `UnknownRuntime` / `UnknownWorker``404 NOT_FOUND`
- テスト追加。
- 同一 `worker_id` を持つ複数 runtime fixture で lookup が `runtime_id` scoped であることを確認。
- unknown runtime と unknown worker が別 error として返り、crate-level `Error` にも正しく mapping されることを確認。
変更ファイル:
- `crates/workspace-server/src/hosts.rs`
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/src/server.rs`
Validation:
- `cargo fmt --all`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- 現時点で worker detail API route はまだないため、`RuntimeRegistry::worker(runtime_id, worker_id)` の authority boundary は registry 内部 API とテストで確立している。将来 route を追加する際は path/query でも `runtime_id + worker_id` を必須にする必要がある。
- fixture runtime は routing/error 境界確認用の test-only 実装で、embedded/remote の実接続は次 Ticket の責務として残している。
---
<!-- event: review author: yoi-reviewer-00001KVZKSV6C-runtime-registry at: 2026-06-25T20:41:10Z status: approve -->
## Review: approve
Review result: approve
確認結果:
- `RuntimeRegistry::worker(runtime_id, worker_id)` に変更され、`worker_id` 単独走査ではなく `runtime_id` で対象 Runtime を確定してから lookup する形になっている。
- `UnknownRuntime``UnknownWorker { runtime_id, worker_id }` が分離され、crate `Error` / server 404 mapping も更新されている。
- 同一 `worker_id` が複数 Runtime に存在する multi-runtime fixture test が追加され、誤 routing しないことを確認している。
- unknown runtime / known runtime + missing worker の error mapping test も追加されている。
- embedded worker-runtime 接続、remote HTTP、REST server 等の scope creep は見当たらない。
- local compatibility source の既存 projection / path 非露出 / ID boundedness 系テストは維持されている。
- read-only で `git diff --check f6fd7b63 d7c4396c` は問題なし。Cargo/Nix は read-only 指示のため再実行していない。
前回ブロッカーは解消済みと判断する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-25T20:44:19Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KVZKSV6C-backend-runtime-registry` を orchestration branch に merge 済み。
- Merge commit: `fb023aab merge: 00001KVZKSV6C runtime registry foundation`
Included implementation commits:
- `f6fd7b63 feat: add workspace runtime registry source boundary`
- `d7c4396c fix: scope workspace worker lookup by runtime`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success21 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。前回 blocker だった `runtime_id + worker_id` authority と unknown-runtime / unknown-worker typed error boundary は解消済み。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-25T20:44:27Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Backend RuntimeRegistry foundation の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `fb023aab merge: 00001KVZKSV6C runtime registry foundation`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success21 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- workspace-server の RuntimeRegistry foundation を worker-runtime 向け identity/projection/error boundary に整理。
- embedded runtime connection / remote HTTP / REST server / Web Console は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T03:09:01Z -->
## Implementation report
Cleanup report:
- Registry Coder/Reviewer child Workers were no longer present in the spawned-child registry at cleanup time, so no StopPod action was available for them.
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZKSV6C-backend-runtime-registry`
- Child implementation branch removed:
- `work/00001KVZKSV6C-backend-runtime-registry`
Operational note:
- User noted StopPod should be run one-by-one rather than through parallel multi-tool execution; future cleanup will follow that.
---

View File

@ -1,2 +1,5 @@
{"id":"orch-plan-20260625-164457-1","ticket_id":"00001KVZQHPNY","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. Profile/config bundle sync depends on worker-runtime core `00001KVZBCQH4`, which is currently inprogress and under review. Do not start sync implementation until core CreateWorkerRequest/Profile boundary is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-25T16:44:57Z"} {"id":"orch-plan-20260625-164457-1","ticket_id":"00001KVZQHPNY","kind":"blocked_by","related_ticket":"00001KVZBCQH4","note":"Queue routing checked after Dashboard Queue. Profile/config bundle sync depends on worker-runtime core `00001KVZBCQH4`, which is currently inprogress and under review. Do not start sync implementation until core CreateWorkerRequest/Profile boundary is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-25T16:44:57Z"}
{"id":"orch-plan-20260625-165606-2","ticket_id":"00001KVZQHPNY","kind":"waiting_capacity_note","note":"Core dependency is now done, but this bundle-sync Ticket is left queued in this acceptance pass because Backend Registry foundation and FS store were accepted first. Bundle sync likely touches `worker-runtime` creation/profile boundary and Backend Registry availability semantics, so it should start after at least the foundation branch shape is reviewed or merged to avoid design/API churn.","author":"yoi-orchestrator","at":"2026-06-25T16:56:06Z"} {"id":"orch-plan-20260625-165606-2","ticket_id":"00001KVZQHPNY","kind":"waiting_capacity_note","note":"Core dependency is now done, but this bundle-sync Ticket is left queued in this acceptance pass because Backend Registry foundation and FS store were accepted first. Bundle sync likely touches `worker-runtime` creation/profile boundary and Backend Registry availability semantics, so it should start after at least the foundation branch shape is reviewed or merged to avoid design/API churn.","author":"yoi-orchestrator","at":"2026-06-25T16:56:06Z"}
{"id":"orch-plan-20260626-051843-3","ticket_id":"00001KVZQHPNY","kind":"waiting_capacity_note","note":"Core/foundation dependencies are now done, but config bundle sync is left queued while embedded Backend RuntimeRegistry connection `00001KVZSGT0Q` is accepted/inprogress. Bundle sync likely touches worker creation/profile boundary and Backend Registry availability semantics; start after embedded connection branch shape is reviewed or merged to avoid API churn.","author":"yoi-orchestrator","at":"2026-06-26T05:18:43Z"}
{"id":"orch-plan-20260626-054922-4","ticket_id":"00001KVZQHPNY","kind":"waiting_capacity_note","note":"Config bundle sync is left queued while remote Runtime process connection `00001KVZSGT14` is accepted/inprogress. The relation says v0 remote integration can use builtin/default fallback, and starting both would risk churn in worker creation/config routing surfaces.","author":"yoi-orchestrator","at":"2026-06-26T05:49:22Z"}
{"id":"orch-plan-20260626-063205-5","ticket_id":"00001KVZQHPNY","kind":"accepted_plan","note":"Worker-runtime core, embedded/remote Runtime Registry, REST/WS foundation are done. Previous waiting-capacity notes are resolved; no inprogress remains.","accepted_plan":{"summary":"Runtime へ同期可能な Profile/config bundle model、runtime-side bundle store/status/digest validation、worker create integration、Backend-to-Runtime sync/availability boundary を実装する。Secret values/raw paths/plugin execution/full package manager は扱わない。","branch":"work/00001KVZQHPNY-runtime-config-bundles","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZQHPNY-runtime-config-bundles","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/worker-runtime` / `crates/workspace-server` と必要な Cargo/package files の write scope を委譲する。reviewer Worker は read-only で secret/path exclusion、digest/provenance, Runtime/Backend sync semantics, typed errors, fallback-vs-bundle boundary を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T06:32:05Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'RuntimeへProfile/config bundleを同期する' title: 'RuntimeへProfile/config bundleを同期する'
state: 'queued' state: 'done'
created_at: '2026-06-25T15:49:30Z' created_at: '2026-06-25T15:49:30Z'
updated_at: '2026-06-25T16:56:06Z' updated_at: '2026-06-26T07:40:38Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T16:44:39Z' queued_at: '2026-06-25T16:44:39Z'

View File

@ -60,3 +60,389 @@ Escalate if:
- bundle sync のために REST server / FS store / Plugin manager 実装を同時に要求する形になりそうな場合。 - bundle sync のために REST server / FS store / Plugin manager 実装を同時に要求する形になりそうな場合。
--- ---
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T06:32:31Z -->
## Decision
Routing decision: implementation_ready
Reason:
- `00001KVZBCQH4` worker-runtime core、Backend RuntimeRegistry foundation、embedded/remote Runtime connection、REST/WS foundation は done。
- 前回の waiting-capacity reason は解消済みで、現在 `inprogress` は 0 件。
- Ticket body は config bundle model、sync API、worker creation integration、Backend responsibility、Plugin/host policy boundary、Non-goals、acceptance criteria を明記している。
- Plugin package bytes vs package ref の詳細などは実装時の local design latitude として扱える。Secret value sync / full Plugin manager / actual host mount path sync は Non-goals。
Evidence checked:
- Ticket body: digest/revision/provenance、bundle content model、secret/raw path exclusion、Runtime sync API、CreateWorkerRequest integration、typed errors、Backend responsibility、Non-goals、validation。
- Relations: only blocking dependency `00001KVZBCQH4` is done。Remote Runtime integration has related relation only and is now done.
- Orchestration plan: accepted plan `orch-plan-20260626-063205-5` を記録。
- Workspace state: orchestration worktree clean、no active inprogress。
IntentPacket:
Intent:
- `worker-runtime` と Backend に Profile/config bundle sync boundary を追加し、Runtime が bundle digest / profile selector を検証して Worker creation に使えるようにする。
Binding decisions / invariants:
- Bundle に secret values、raw socket/session path、runtime-local mount actual path、host-local cache path を含めない。
- Secret/mount/network/shell/git availability は host-local policy / grant / secret ref として表現し、Runtime host が最終判断する。
- Backend は Runtime credential / direct endpoint / raw bundle storage path を Browser に渡さない。
- Backend が巨大な fully-resolved WorkerSpec を毎回送る設計にはしない。Worker creation は intent + profile selector + bundle ref + capabilities の境界を保つ。
- Builtin/default fallback は残すが、synced bundle mode と責務を区別する。
- Full Plugin package manager / registry / signature policy / secret value sync / Web Console completion は実装しない。
Requirements / acceptance criteria:
- Config bundle domain type が digest / revision / workspace id / created_at / provenance を持つ。
- Runtime は bundle を保存・一覧/確認し、digest を検証できる。
- `CreateWorkerRequest` / worker creation path が profile selector + config bundle ref を扱う。
- Missing bundle / digest mismatch / invalid profile / unsupported declaration は typed error。
- Embedded Runtime は direct lib API で sync 可能。
- Networked Runtime 用 REST sync API shape または endpoint がある。
- Backend は Runtime へ bundle sync し、bundle availability を確認できる。
- Tests distinguish builtin/default fallback vs synced bundle mode。
Implementation latitude:
- Module split, exact JSON/domain structs, digest computation details, minimal profile resolution depth, package ref vs inline descriptor representation are Coder choices within the invariants.
- For v0, `ResolvedWorkerSpec` / host-local policy enforcement may be a typed boundary with focused validation rather than full Plugin execution.
- If Profile/resource loading from existing project crates is too broad for this slice, define stable bundle structs and sync/validation semantics first, then escalate for deeper integration.
Escalate if:
- Secret values or host-local actual paths appear necessary in bundle content.
- Full Plugin package manager/signature policy is required to satisfy acceptance.
- Existing `worker-runtime` / Backend API must be redesigned broadly.
- Browser-facing API would need to receive Runtime credentials or raw bundle storage paths.
Validation:
- `cargo fmt --all`
- `cargo test -p worker-runtime`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Secret/raw path leakage。
- Bundle digest/provenance instability or mismatch bugs。
- Blurring builtin/default fallback with synced bundle mode。
- Over-implementing Plugin manager / policy enforcement beyond v0 boundary。
- Remote Runtime sync endpoint leaking endpoint/token/path details to Browser。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T06:32:50Z from: queued to: inprogress reason: routing_accepted_after_runtime_foundation_done field: state -->
## State changed
Routing decision: implementation_ready。
worker-runtime core / Registry foundation / embedded+remote Runtime connection / REST+WS foundation は done。Ticket body / relations / workspace state / accepted plan を確認し、Profile/config bundle sync slice は unblocked と判断した。accepted plan と IntentPacket は thread / orchestration plan に記録済み。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T06:35:25Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KVZQHPNY-runtime-config-bundles`
- Branch: `work/00001KVZQHPNY-runtime-config-bundles`
- Coder Worker spawned: `yoi-coder-00001KVZQHPNY-config-bundles`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/worker-runtime/**`, `crates/workspace-server/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Queued capacity notes:
- `00001KVZ9JGK0` remains queued to avoid overlapping Web Console/API surface changes while Profile/config sync is inprogress.
- `00001KW04A8K6` remains queued to avoid TUI migration against unstable worker creation/profile semantics.
---
<!-- event: implementation_report author: yoi-coder-00001KVZQHPNY-config-bundles at: 2026-06-26T07:08:31Z -->
## Implementation report
Implementation report:
実装完了。指定 worktree / branch 内で commit 済み。
Commit:
- `abab1af2 feat: add runtime config bundle sync`
変更ファイル:
- `Cargo.lock`
- `package.nix`
- `crates/worker-runtime/Cargo.toml`
- `crates/worker-runtime/src/lib.rs`
- `crates/worker-runtime/src/catalog.rs`
- `crates/worker-runtime/src/config_bundle.rs`
- `crates/worker-runtime/src/error.rs`
- `crates/worker-runtime/src/fs_store.rs`
- `crates/worker-runtime/src/http_server.rs`
- `crates/worker-runtime/src/runtime.rs`
- `crates/workspace-server/src/hosts.rs`
- `crates/workspace-server/src/server.rs`
実装概要:
### worker-runtime
- `config_bundle` module を追加。
- `ConfigBundle`
- `ConfigBundleMetadata`
- `ConfigBundleProvenance`
- `ConfigProfileDescriptor`
- `ConfigDeclaration`
- `ConfigBundleSummary`
- `ConfigBundleAvailability`
- Bundle metadata に digest / revision / workspace id / created_at / provenance/source metadata を保持。
- `ConfigBundleRef``id + digest` に拡張。
- Runtime direct lib API を追加。
- `store_config_bundle`
- `list_config_bundles`
- `check_config_bundle`
- Worker creation で profile selector + config_bundle ref を検証。
- bundle missing
- digest mismatch
- invalid profile selector
- unsupported declaration
を typed `RuntimeError` として返す。
- builtin/default fallback と synced bundle mode を区別。
- bundle なしでは `RuntimeDefault` / `Builtin` fallback を許可。
- `Named` profile は synced bundle ref 必須。
- FS snapshot に config bundle store を永続化。
- Runtime REST API を追加。
- `GET /v1/config-bundles`
- `POST /v1/config-bundles`
- `GET /v1/config-bundles/{bundle_id}/availability?digest=...`
- Bundle validation で secret values / host-local path 的な値を bundle content に持ち込まない前提の ref/grant/policy boundary を enforced する最小チェックを追加。
### workspace-server / Backend
- `WorkerSpawnRequest``profile` / `config_bundle` / `requested_capabilities` を追加。
- `RuntimeRegistry` / runtime host boundary に bundle sync/check/list を追加。
- Embedded Runtime backend で direct lib sync/check/create が動作。
- Remote Runtime backend で Runtime REST sync/check endpoint を利用。
- Workspace API に backend-facing endpoint を追加。
- `POST /api/runtimes/{runtime_id}/config-bundles`
- `GET /api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability?digest=...`
- Browser/API 側に Runtime credentials / direct endpoint / raw bundle storage path は出していない。
Validation:
- `cargo fmt --all`: success
- `cargo test -p worker-runtime`: success10 passed
- `cargo test -p yoi-workspace-server`: success34 passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Notes:
- `sha2``worker-runtime` direct dependency に追加したため、`Cargo.lock` と `package.nix``cargoHash` も更新。
Residual risks / review focus:
- Bundle digest canonicalization は v0 の typed domain fields ベース。将来 declaration schema が増える場合、digest 対象フィールドの拡張方針を確認してほしい。
- Bundle content の secret value / host-local path 禁止は v0 として文字列フィールドへの conservative validation。完全な policy/signature/package-manager ではない。
- Remote Runtime の bundle availability check は query string に digest を載せる最小 REST shape。bundle id の URL encoding 方針は今後必要なら強化対象。
- Backend API endpoint は sync/check のみ追加し、Browser 完成 UX や full Plugin package manager / signature policy は実装していない。
---
<!-- event: review author: yoi-reviewer-00001KVZQHPNY-config-bundles at: 2026-06-26T07:18:50Z status: request_changes -->
## Review: request changes
Review result: request_changes
読取専用レビュー結果。`abab1af2` の差分・該当ソースを確認し、追加で `git diff --check HEAD^ HEAD` のみ実行したcargo/nix はファイル生成を避けるため再実行せず、報告値として扱った)。
ブロッカー:
1. **bundle content の禁止境界がまだ満たせていない。**
`ConfigDeclaration.reference` は自由文字列で、検証は `validate_boundary_text` の限定的な substring 判定のみ。
- `crates/worker-runtime/src/config_bundle.rs:124-129`
- `crates/worker-runtime/src/config_bundle.rs:266-294`
例えば `.cache/yoi`、`.yoi/sessions/foo.jsonl`、`pods/foo/sock` のような相対 cache/session/socket 形や、`SecretRef.reference` に平文 secret 風文字列を入れても拒否されない。Ticket の invariant「Bundles must not contain secret values / raw socket/session paths / host-local cache paths」を API 型・検証境界として保証できていないため修正が必要。型を分けるか、少なくとも ref/grant/policy の許可形式を明示して拒否テストを追加すること。
2. **Backend remote availability check の bundle id canonicalization/encoding が不十分。**
Runtime REST は `{bundle_id}` path segment を使うが、bundle id 側の検証は `/`, `?`, `&` などを拒否せず、remote client は URL encode せずに埋め込んでいる。
- REST route: `crates/worker-runtime/src/http_server.rs:172-177`
- remote client: `crates/workspace-server/src/hosts.rs:1784-1788`
direct/POST store では受け入れられる id が remote check では経路破壊・誤解釈され得るため、Remote/Embedded の sync/check semantics が一致しない。path-safe id へ正規化/検証するか、ID を安全にエンコード/別ボディ化し、対応テストを追加すること。
3. **Browser-facing diagnostics に remote Runtime の raw store path が漏れる経路がある。**
Runtime REST error は `RuntimeError::to_string()` をそのまま返し、`StoreIo/StoreMissing/StoreCorrupt` は path を Display する。Backend remote client はその remote message を診断として Browser-facing `ConfigBundleSyncResult/CheckResult` に載せる。
- path を含む Display: `crates/worker-runtime/src/error.rs:65-80`
- REST error message: `crates/worker-runtime/src/http_server.rs:785-789`
- Backend diagnostic relay: `crates/workspace-server/src/hosts.rs:2476-2505`
「Backend must not expose ... raw bundle storage paths to Browser」に反する。remote Runtime の internal path は Backend 側でサニタイズし、embedded と同様に内部 path 非公開の診断へ畳む必要がある。
確認できた良い点:
- digest/revision/workspace_id/created_at/provenance を持つ bundle domain type は追加されている。
- Runtime store/list/check、profile + bundle ref の Worker 作成検証、missing/mismatch/invalid profile/unsupported declaration の typed error は概ね入っている。
- builtin/default fallback と synced bundle mode のテスト分離はある。
- Embedded direct sync と remote REST sync/check の骨格はある。
- package.nix の cargoHash は Cargo.lock 変更に合わせて更新されている。
---
<!-- event: implementation_report author: yoi-coder-00001KVZQHPNY-config-bundles at: 2026-06-26T07:34:04Z -->
## Implementation report
Follow-up implementation report after review request_changes:
Commit:
- `4867ab21 fix: harden runtime config bundle boundary`
対応内容:
### 1. Bundle content boundary 強化
- `ConfigBundle` / `ConfigBundleRef` validation を強化。
- Bundle id を path-safe stable identifier に制限。
- `/`, `?`, `&`, `#`, whitespace などを拒否。
- digest を lowercase SHA-256 hex 64 chars として検証。
- `ConfigDeclaration.reference` は free string のままにせず、kind ごとに許可 prefix を限定。
- `SecretRef`: `secret:`, `secret-ref:`, `vault:`, `keyring:`
- `MountGrant`: `mount:`, `mount-grant:`
- `NetworkPolicy`: `network:`, `network-policy:`
- `ShellPolicy`: `shell:`, `shell-policy:`
- `GitPolicy`: `git:`, `git-policy:`
- `CapabilityGrant`: `capability:`, `capability-grant:`
- reference token では `/`, `\`, `?`, `&`, `#`, `%`, `=`, whitespace を拒否。
- relative cache/session/socket/path-like forms を拒否するテストを追加。
- `.cache/yoi`
- `.yoi/sessions/foo.jsonl`
- `pods/foo/sock`
- `password=hunter2`
- `hunter2-secret-value`
### 2. Remote availability check の path safety
- Runtime 側で unsafe bundle id を拒否。
- Remote client 側の bundle availability path construction で bundle id / digest を percent-encode。
- `bundle/1?x``/v1/config-bundles/bundle%2F1%3Fx/availability?...` になることを mock HTTP test で確認。
- Direct/Embedded と Remote で path segment 解釈が分岐しにくいようにした。
### 3. Browser-facing diagnostics の raw path leak 防止
- Remote Runtime HTTP error diagnostic で remote error message をそのまま転送しないように変更。
- Browser-facing diagnostic は remote code / HTTP status のみを含む sanitized message に変換。
- mock remote response に raw store path / session path を含め、`ConfigBundleSyncResult` / `ConfigBundleCheckResult` に漏れないことをテスト追加。
Validation:
- `cargo fmt --all`: success
- `cargo test -p worker-runtime`: success13 passed
- `cargo test -p yoi-workspace-server`: success35 passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- `ConfigDeclaration.reference` は v0 として prefix/token allowlist で制限。将来、より構造化された enum payload に移す余地はある。
- Secret ref は「値を同期しない」境界として typed prefix 必須にしたが、ref 名自体に何を許すかは今後 policy 化できる。
- Remote error message は全体的に sanitized されるため、詳細調査は backend/runtime logs 側を見る前提。
---
<!-- event: review author: yoi-reviewer-00001KVZQHPNY-config-bundles at: 2026-06-26T07:38:26Z status: approve -->
## Review: approve
Review result: approve
再レビュー結果: 前回ブロッカーは解消されていると判断する。
確認内容:
- `4867ab21 fix: harden runtime config bundle boundary` の差分と関連実装を読取専用で確認。
- `git diff --check HEAD^ HEAD` は問題なし。
- cargo/nix 系はファイル生成を避けるため再実行せず、報告された成功結果を前提確認扱い。
確認できた解消点:
- bundle id は path-safe に制限され、`/`, `?`, `&`, `#`, whitespace 等が拒否される。
- digest は lowercase SHA-256 hex 64 chars として検証される。
- `ConfigDeclaration.reference` は kind 別 prefix + token 制限になり、cache/session/socket/path 風・secret value 風の拒否テストも追加されている。
- remote availability path は percent-encode され、Runtime 側でも unsafe id が拒否されるため remote/embedded の意味差が縮んでいる。
- remote Runtime error の raw message は Backend 診断へ中継されず、Browser-facing result は HTTP status / remote code ベースにサニタイズされている。raw store/session path 非漏洩テストも確認。
- builtin/default fallback と synced bundle mode の区別、typed error、sync/check API 境界は維持されている。
- Plugin package manager/signature/WebConsole 方面の scope creep は見当たらない。
- `package.nix``cargoHash` 更新も反映済み。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T07:39:30Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KVZQHPNY-runtime-config-bundles` を orchestration branch に merge 済み。
- Merge commit: `7e8a8cfa merge: 00001KVZQHPNY runtime config bundles`
Included implementation commits:
- `abab1af2 feat: add runtime config bundle sync`
- `4867ab21 fix: harden runtime config bundle boundary`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime`: success13 tests passed
- `cargo test -p yoi-workspace-server`: success35 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。前回 blockers だった bundle content boundary、bundle id path safety、remote diagnostics raw path non-leak は解消済み。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T07:39:39Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Runtime Profile/config bundle sync の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `7e8a8cfa merge: 00001KVZQHPNY runtime config bundles`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime`: success13 tests passed
- `cargo test -p yoi-workspace-server`: success35 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- Config bundle domain/sync/check、worker creation integration、embedded/remote Backend sync/check boundary を追加。
- Secret value sync / full Plugin package manager/signature policy / Web Console completion は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T07:40:38Z -->
## Implementation report
Cleanup report:
- Child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KVZQHPNY-config-bundles`
- `yoi-reviewer-00001KVZQHPNY-config-bundles`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZQHPNY-runtime-config-bundles`
- Child implementation branch removed:
- `work/00001KVZQHPNY-runtime-config-bundles`
Operational note:
- StopPod was executed sequentially, not in parallel.
---

View File

@ -1 +1,2 @@
{"id":"orch-plan-20260625-163225-1","ticket_id":"00001KVZSGT0Q","kind":"blocked_by","related_ticket":"00001KVZKSV6C","note":"Queue routing checked after Dashboard Queue. Embedded Runtime connection depends on Backend RuntimeRegistry foundation `00001KVZKSV6C`, which is still queued and itself blocked by inprogress worker-runtime core `00001KVZBCQH4`. Do not start this Ticket until the foundation dependency is accepted/completed.","author":"yoi-orchestrator","at":"2026-06-25T16:32:25Z"} {"id":"orch-plan-20260625-163225-1","ticket_id":"00001KVZSGT0Q","kind":"blocked_by","related_ticket":"00001KVZKSV6C","note":"Queue routing checked after Dashboard Queue. Embedded Runtime connection depends on Backend RuntimeRegistry foundation `00001KVZKSV6C`, which is still queued and itself blocked by inprogress worker-runtime core `00001KVZBCQH4`. Do not start this Ticket until the foundation dependency is accepted/completed.","author":"yoi-orchestrator","at":"2026-06-25T16:32:25Z"}
{"id":"orch-plan-20260626-051805-2","ticket_id":"00001KVZSGT0Q","kind":"accepted_plan","note":"Dependencies are unblocked: `00001KVZKSV6C` RuntimeRegistry foundation is done; worker-runtime core is done. No active inprogress remains after WS proxy cleanup.","accepted_plan":{"summary":"Backend RuntimeRegistry に embedded `worker_runtime::Runtime` source を接続し、backend-internal Runtime として summary/status/capabilities、worker list/detail/create/input/transcript projection を direct lib call で扱えるようにする。remote process/FS/REST/WS/Web Console/Profile sync は扱わない。","branch":"work/00001KVZSGT0Q-embedded-runtime-registry","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZSGT0Q-embedded-runtime-registry","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/workspace-server` 中心の narrow write scope を委譲する。reviewer Worker は read-only で embedded Runtime registration、runtime_id+worker_id routing、no internal path/credential leak、local compatibility separation を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T05:18:05Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Backend RuntimeRegistryにembedded worker-runtimeを接続する' title: 'Backend RuntimeRegistryにembedded worker-runtimeを接続する'
state: 'queued' state: 'done'
created_at: '2026-06-25T16:23:58Z' created_at: '2026-06-25T16:23:58Z'
updated_at: '2026-06-25T16:32:35Z' updated_at: '2026-06-26T05:47:56Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T16:31:30Z' queued_at: '2026-06-25T16:31:30Z'

View File

@ -59,3 +59,257 @@ Escalate if:
- worker-runtime core API が embedded Backend integration に必要な create/send/projection semantics を満たさない。 - worker-runtime core API が embedded Backend integration に必要な create/send/projection semantics を満たさない。
--- ---
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T05:18:27Z -->
## Decision
Routing decision: implementation_ready
Reason:
- `00001KVZKSV6C` Backend RuntimeRegistry foundation は done。`00001KVZBCQH4` worker-runtime core も done。
- 本 Ticket の scope は embedded Runtime handle を Backend Registry に接続することで、remote Runtime process / FS store / REST command server / event stream / Web Console / config bundle sync は Non-goals。
- 現在 `inprogress` は 0 件。実装 surface は主に `crates/workspace-server` の RuntimeRegistry source/route であり、queued の remote/WebConsole/TUI は本 Ticket 完了後に判断すべき依存関係。
Evidence checked:
- Ticket body: embedded Runtime registration、direct lib call Worker operations、Backend API exposure、Non-goals、acceptance criteria。
- Relations: outgoing dependency `00001KVZKSV6C` は done。incoming `00001KVZ9JGK0` / `00001KW04A8K6` は後続であり blocker ではない。
- Orchestration plan: accepted plan `orch-plan-20260626-051805-2` を記録。
- Workspace state: orchestration worktree clean、WS proxy Ticket は done/cleanup 済み。
IntentPacket:
Intent:
- Workspace Backend の `RuntimeRegistry` に embedded `worker_runtime::Runtime` source を追加し、`backend-internal` Runtime として扱えるようにする。
Binding decisions / invariants:
- Embedded Runtime は HTTP endpoint / token / socket path / session path を持たない。
- Browser-facing API は `runtime_id + worker_id` authority で扱い、embedded Runtime internals / store path / provider credentials を露出しない。
- v0 は memory store / builtin/default Profile fallback / toolsなし Worker でよい。
- Remote Runtime process client、FS store、REST command server、event stream server、Web Console、Profile/config bundle sync は実装しない。
- Local compatibility source は残し、embedded Runtime source と diagnostics / implementation kind で区別する。
Requirements / acceptance criteria:
- Workspace Backend が embedded `worker_runtime::Runtime` を生成・保持し、RuntimeRegistry に登録できる。
- Backend API から `backend-internal` Runtime の summary/status/capabilities を確認できる。
- Registry が embedded Runtime の worker list/detail/create/send input/transcript projection を direct lib call で扱える。
- v0 toolsなし Worker が create でき、input acceptance まで確認できる。
- socket/session/runtime internal store/provider credential が Browser-facing API に出ない。
- Focused workspace-server tests cover embedded runtime registration and routing。
Implementation latitude:
- Existing `/api/workers` / runtime list に含めるか、新 runtime-scoped endpoint に寄せるかは Coder が既存 API shape とテスト容易性に基づいて選び、実装報告で方針を明記する。
- Internal source enum/handle/trait の分割、typed error mapping、test fixture の形は既存 `hosts.rs` / `server.rs` に合わせてよい。
Escalate if:
- `worker-runtime` public API の大幅変更が必要になる。
- Profile/config bundle sync を同時に実装しないと embedded Worker create が成立しない場合。
- Browser-facing API schema を大きく変える必要がある場合。
Validation:
- `cargo fmt --all`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Embedded Runtime internals/path/credentials の漏洩。
- Local compatibility source と embedded Runtime source の混同。
- `runtime_id + worker_id` routing ではなく worker_id-only routing に戻る regression。
- Non-goalsremote/REST/WS/WebConsole/Profile syncへの scope creep。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T05:18:35Z from: queued to: inprogress reason: routing_accepted_after_registry_foundation_done field: state -->
## State changed
Routing decision: implementation_ready。
Dependency `00001KVZKSV6C` Backend RuntimeRegistry foundation と `00001KVZBCQH4` worker-runtime core は done。Ticket body / relations / workspace state / accepted plan を確認し、embedded worker-runtime Registry connection slice は unblocked と判断した。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T05:19:30Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KVZSGT0Q-embedded-runtime-registry`
- Branch: `work/00001KVZSGT0Q-embedded-runtime-registry`
- Coder Worker spawned: `yoi-coder-00001KVZSGT0Q-embedded-runtime`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/workspace-server/**` and root `Cargo.lock` if dependency lock changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Queued capacity note:
- `00001KVZQHPNY` remains queued while this embedded Runtime connection is inprogress to avoid worker creation/profile boundary API churn.
- Remote/WebConsole/TUI dependent Tickets remain queued until embedded/remote prerequisite chain is done.
---
<!-- event: implementation_report author: yoi-coder-00001KVZSGT0Q-embedded-runtime at: 2026-06-26T05:41:14Z -->
## Implementation report
Implementation report:
実装完了。現在のブランチにコミット済み。
Commit:
- `c8209285 feat: add embedded workspace runtime registry`
変更ファイル:
- `crates/workspace-server/src/hosts.rs`
- `crates/workspace-server/src/server.rs`
実装概要:
- `RuntimeRegistry` に backend-internal な embedded `worker_runtime::Runtime` source を追加。
- `EmbeddedWorkerRuntime::new_memory(...)`
- `RuntimeRegistry::for_workspace(...)`
- `RuntimeRegistry::register(...)`
- local compatibility source は既存の `local-worker-runtime` として維持し、embedded source は `embedded-worker-runtime` として distinct な kind / diagnostics に分離。
- embedded Runtime は browser/API に対して以下を露出しない projection にしている。
- HTTP endpoint
- token / credential
- socket / session path
- store path
- provider identity
- Worker 操作は `runtime_id + worker_id` で routing。
- worker create
- worker detail
- input acceptance
- bounded transcript projection
- v0 として in-memory / tools-less Worker create を実装。
- Ticket role では builtin profile fallback (`builtin:coder` など)
- Workspace companion/orchestrator では runtime default profile fallback
- `SocketReady` acceptance は embedded Runtime では明示的に reject。
追加/拡張 API:
- `POST /api/runtimes/{runtime_id}/workers`
- `GET /api/runtimes/{runtime_id}/workers/{worker_id}`
- `POST /api/runtimes/{runtime_id}/workers/{worker_id}/input`
- `GET /api/runtimes/{runtime_id}/workers/{worker_id}/transcript?start=...&limit=...`
既存 API への反映:
- `GET /api/runtimes`
- `GET /api/workers`
にも embedded runtime / embedded worker projection が載る。
テスト:
- embedded runtime registration / routing
- local compatibility source と embedded source の分離
- embedded Worker create
- input acceptance
- transcript projection
- wrong runtime_id での worker 操作が通らないこと
- browser-facing projection に内部 path / credential / socket/session 類が混ざらないこと
- `SocketReady` acceptance が embedded Runtime で reject されること
Validation:
- `cargo fmt --all`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- v0 は intentionally in-memory + tools-less Worker。LLM 実行、remote Runtime process client、FS store、REST command server、event stream server、Web Console、Profile/config sync は未実装。
- embedded runtime の API projection は内部情報を出さない方針だが、diagnostics / evidence / summary JSON に internal identity が混ざっていないか要確認。
- `requested_worker_name` は authority として使わず、embedded Runtime 側の opaque worker id を採用。UX 的表示名が必要なら別 Ticket で profile/config bundle や display metadata の設計が必要。
---
<!-- event: review author: yoi-reviewer-00001KVZSGT0Q-embedded-runtime at: 2026-06-26T05:46:25Z status: approve -->
## Review: approve
Review result: approve
確認結果:
- `RuntimeRegistry``LocalWorkerRuntime` と別種別の `EmbeddedWorkerRuntime` が登録され、`backend-internal` Runtime として summary/status/capabilities を返す構成になっている。
- embedded 側は `worker_runtime::Runtime::new_memory` を保持し、worker list/detail/create/send input/transcript は adapter 経由で direct lib call している。REST/remote process/FS store/WebConsole/Profile sync などへの scope creep は見当たらない。
- embedded Runtime summary/worker projection は endpoint/token/socket/session path/store path/provider credential を露出しておらず、capabilities も内部実装詳細を出さない形。
- local compatibility source は `local-pod-runtime`、embedded source は `embedded-worker-runtime` として kind/diagnostics が分離されている。
- API routing は detail/input/transcript が `/api/runtimes/{runtime_id}/workers/{worker_id}` 系で、worker_id-only の操作経路への回帰は見当たらない。
- tests は registration/routing/projection/error/identity boundary を含み、embedded worker 作成・input acceptance・transcript projection と誤 runtime/worker の失敗系も確認されている。
検証:
- read-only 指示に従い、ビルド/テスト再実行やファイル変更は行わず、対象 commit の差分・実装・テストを静的確認した。報告済み validation 結果と矛盾する点はない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T05:47:01Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KVZSGT0Q-embedded-runtime-registry` を orchestration branch に merge 済み。
- Merge commit: `e0cc7acf merge: 00001KVZSGT0Q embedded runtime registry`
Included implementation commit:
- `c8209285 feat: add embedded workspace runtime registry`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success29 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。embedded Runtime registration/routing、internal path/credential non-leak、local compatibility source separation、runtime_id+worker_id authority に blocker なし。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T05:47:09Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Backend RuntimeRegistry embedded worker-runtime connection の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `e0cc7acf merge: 00001KVZSGT0Q embedded runtime registry`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success29 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- Backend RuntimeRegistry に embedded `worker_runtime::Runtime` source を接続。
- Remote process / FS/REST/WS server implementation / Web Console / Profile config sync は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T05:47:56Z -->
## Implementation report
Cleanup report:
- Child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KVZSGT0Q-embedded-runtime`
- `yoi-reviewer-00001KVZSGT0Q-embedded-runtime`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZSGT0Q-embedded-runtime-registry`
- Child implementation branch removed:
- `work/00001KVZSGT0Q-embedded-runtime-registry`
Operational note:
- StopPod was executed sequentially, not in parallel.
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260625-203613-1","ticket_id":"00001KVZSGT14","kind":"blocked_by","related_ticket":"00001KVZKSTJT","note":"Queue routing checked. Remote worker-runtime process connection depends on WebSocket observation proxy `00001KVZKSTJT` and REST command server `00001KVZKSTE2`; REST is now inprogress and WS proxy remains queued, so this Ticket should wait.","author":"yoi-orchestrator","at":"2026-06-25T20:36:13Z"}
{"id":"orch-plan-20260626-054840-2","ticket_id":"00001KVZSGT14","kind":"accepted_plan","note":"Dependencies are now done: FS store, REST command server, WebSocket observation proxy, and RuntimeRegistry foundation. Related profile/config sync is not a blocking relation for v0, per existing relation note.","accepted_plan":{"summary":"Backend RuntimeRegistry に remote worker-runtime process handle を追加し、REST command API と WS observation API を Backend-owned client として route/proxy できるようにする。Dynamic registration/full auth/Web Console/Profile sync は扱わない。","branch":"work/00001KVZSGT14-remote-runtime-registry","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZSGT14-remote-runtime-registry","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/workspace-server` と必要な Cargo/package files の write scope を委譲する。reviewer Worker は read-only で remote endpoint/token non-leak、runtime_id+worker_id authority、REST/WS client error mapping、embedded/local separation を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T05:48:40Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Backend RuntimeRegistryにremote worker-runtime processを接続する' title: 'Backend RuntimeRegistryにremote worker-runtime processを接続する'
state: 'queued' state: 'done'
created_at: '2026-06-25T16:23:58Z' created_at: '2026-06-25T16:23:58Z'
updated_at: '2026-06-25T20:34:35Z' updated_at: '2026-06-26T06:30:50Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T20:34:35Z' queued_at: '2026-06-25T20:34:35Z'

View File

@ -30,4 +30,344 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-25T20:36:34Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Dashboard Queue 後に Ticket / relations / workspace state を確認した。
- 本 Ticket は WebSocket observation proxy `00001KVZKSTJT` と REST command server `00001KVZKSTE2` を前提にする remote worker-runtime process connection。
- `00001KVZKSTE2` は現在 inprogress、`00001KVZKSTJT` は queued/blocked。remote process connection を先に始めると transport/API shape を先取りして固定するため開始しない。
Evidence checked:
- Ticket body: remote Runtime process接続、Backend RuntimeRegistry source、REST/WebSocket client boundary、Non-goals。
- Relations: outgoing dependencies include `00001KVZKSTE2` / `00001KVZKSTJT` / `00001KVZKSV6C` 等。
- Orchestration plan: blocker record `orch-plan-20260625-203613-1` を追加。
Next action:
- 本 Ticket は queued のまま待機。
- REST command server と WebSocket observation proxy が done になった後に再 routing する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T05:48:59Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Dependencies are done: `00001KVZKST83` FS store、`00001KVZKSTE2` REST command server、`00001KVZKSTJT` WebSocket observation proxy、`00001KVZKSV6C` RuntimeRegistry foundation。
- Existing relation to `00001KVZQHPNY` is `related`, not blocking, and says v0 can use builtin/default fallback where applicable.
- 本 Ticket は remote Runtime process connection / routing を主目的とし、Dynamic registration / full auth / Web Console / Profile config sync は Non-goals。
- 現在 `inprogress` は 0 件。remote Runtime connection は TUI migration の blocker なので優先して受理する。
Evidence checked:
- Ticket body: remote Runtime client handle、REST command API client、Runtime event stream client/proxy、config/policy boundary、Non-goals、acceptance criteria。
- Relations: outgoing dependencies to FS/REST/WS/Registry foundation are done。Incoming TUI dependency is downstream。
- Orchestration plan: accepted plan `orch-plan-20260626-054840-2` を記録。
- Workspace state: orchestration worktree clean、embedded Runtime registry Ticket done/cleanup 済み。
IntentPacket:
Intent:
- Workspace Backend `RuntimeRegistry` に remote worker-runtime process handle を追加し、Backend-owned REST/WS client で remote Runtime operations を route/proxy できるようにする。
Binding decisions / invariants:
- Browser は remote Runtime base URL / token / direct endpoint を知らない。
- Browser-facing authority は embedded/local と同じ `runtime_id + worker_id`
- Remote command は worker-runtime REST command API に対する Backend-owned client。
- Remote observation は worker-runtime WS observation API に対する Backend-owned client/proxy。
- Dynamic registration、full auth/permission model、Backend Web Console、Profile/config bundle sync は実装しない。
- Embedded/local compatibility source の behavior を壊さず、remote source を implementation kind/diagnostics で区別する。
Requirements / acceptance criteria:
- Config-like data から remote Runtime client handle を登録できる。
- Runtime summary/status/capabilities、worker list/detail/create/input/stop/cancel/transcript/event proxy を route できる。
- Network/auth/timeout/remote unsupported/remote worker not found を typed Backend errors/diagnostics に map する。
- Browser-facing API/WS envelope に remote base URL/token/direct endpoint/socket/session path を露出しない。
- Focused workspace-server tests cover mocked remote routing and error mapping。
Implementation latitude:
- HTTP/WS client dependencies、mock remote test harness、internal source/handle trait shape、error type naming は Coder が既存 `workspace-server` pattern に合わせて選べる。
- v0 config-like data は in-memory/static constructor/test fixture でよい。Dynamic registration は不要。
Escalate if:
- worker-runtime REST/WS public API 変更が必要になる。
- Profile/config sync がないと remote Worker creation が成立しない場合。
- Browser-facing API schema を大きく変える必要がある場合。
Validation:
- `cargo fmt --all`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- remote endpoint/token/direct URL leak。
- worker_id-only routing regression。
- remote REST/WS client errors being collapsed into generic unavailable。
- scope creep into dynamic registration/auth/Web Console/Profile sync。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T05:49:13Z from: queued to: inprogress reason: routing_accepted_after_remote_runtime_prerequisites_done field: state -->
## State changed
Routing decision: implementation_ready。
FS store、REST command server、WebSocket observation proxy、RuntimeRegistry foundation は done。Ticket body / relations / workspace state / accepted plan を確認し、remote worker-runtime process connection slice は unblocked と判断した。Profile/config sync は related であり v0 builtin/default fallback が許容されているため blocker ではない。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T05:50:28Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KVZSGT14-remote-runtime-registry`
- Branch: `work/00001KVZSGT14-remote-runtime-registry`
- Coder Worker spawned: `yoi-coder-00001KVZSGT14-remote-runtime`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/workspace-server/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Queued capacity notes:
- `00001KVZQHPNY` remains queued to avoid worker creation/config routing churn while remote Runtime source is inprogress.
- `00001KVZ9JGK0` remains queued to avoid overlapping Backend/Web Console API surface changes while remote source routing stabilizes.
- `00001KW04A8K6` remains queued because it depends on this remote Runtime source work.
---
<!-- event: implementation_report author: yoi-coder-00001KVZSGT14-remote-runtime at: 2026-06-26T06:16:32Z -->
## Implementation report
Implementation report:
実装完了。マージ・Ticket 更新・close・cleanup は行っていない。
Commit:
- `aeb12b3b8e16a948e6f8387f6fb50fec60c53cc4 feat: add remote runtime registry source`
変更ファイル:
- `crates/workspace-server/src/hosts.rs`
- `crates/workspace-server/src/server.rs`
- `crates/workspace-server/src/lib.rs`
- `crates/workspace-server/Cargo.toml`
- `Cargo.lock`
- `package.nix`
実装概要 / API 変更:
- `RuntimeRegistry` に remote worker-runtime source を追加。
- `RemoteRuntimeConfig`
- `RemoteWorkerRuntime`
- backend-owned REST client による remote Runtime 操作
- backend-owned WS observation source の生成
- Browser-facing の authority は `runtime_id + worker_id` のまま維持。
- remote base URL / token / direct endpoint は API payload に出さない設計。
- remote config の `Debug` も base URL/token を redaction。
- remote Runtime source summary を embedded/local と区別。
- `RuntimeSourceKind::RemoteHttp` を active source として利用。
- diagnostics も remote backend proxy として区別。
- routing 対応:
- runtime summary/status/capabilities
- host list
- worker list/detail
- worker create
- input
- stop
- cancel
- transcript
- WS event proxy source fallback
- Browser/API endpoint 追加:
- `POST /api/runtimes/{runtime_id}/workers/{worker_id}/stop`
- `POST /api/runtimes/{runtime_id}/workers/{worker_id}/cancel`
- error mapping:
- remote auth failure -> `RuntimeOperationFailed` / HTTP 401
- remote timeout -> HTTP 504
- unsupported -> HTTP 501
- other remote operation failures -> HTTP 502
- remote worker not found remains typed worker lookup failure path
- tests:
- mocked remote HTTP routing test
- secret/base URL non-leak assertion
- backend-owned WS observation source assertion
- remote auth error typed mapping test
Validation:
- `cargo fmt --all`: success
- `cargo test -p yoi-workspace-server`: success31 passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success after `package.nix` cargoHash update
Residual risks / review focus:
- remote HTTP client は `reqwest::blocking` を利用。現状の `RuntimeRegistry` trait が sync なので変更量を抑えているが、将来 remote 操作の並行性/latency を重視するなら async trait 化または server handler 側 blocking isolation を検討。
- v0 remote config は static/in-memory registration で、dynamic registration / full auth model / Profile config sync は非実装。
- remote Worker create は既存 embedded mapping と同じ `CreateWorkerRequest::tools_less` ベース。remote 側で Profile/config bundle sync が必要になる場合は別 Ticket 境界。
- WS observation は backend-private source fallback を追加。実 remote WS e2e は mock HTTP unit test ではなく既存 proxy tests + source生成テストでの確認に留めている。
---
<!-- event: review author: yoi-reviewer-00001KVZSGT14-remote-runtime at: 2026-06-26T06:21:31Z status: request_changes -->
## Review: request changes
Review result: request_changes
ブロッカー:
- `RuntimeObservationSourceConfig``#[derive(Debug)]` のまま `endpoint``bearer_token` を保持している(`crates/workspace-server/src/observation.rs:13-19`)。さらに `BackendObservationProxy``#[derive(Debug)]` でこの source map を内包している(`observation.rs:184-187`)。
- remote source ではここに実 remote WS endpoint と token が入る(`hosts.rs:1642-1647`)ため、`format!("{:?}", source/proxy)` や将来の debug log で base URL / direct endpoint / bearer token が漏れる。
- ユーザー指定の focus「API/WS payloads or Debug/log-worthy types で漏らさない」に反している。
確認できた良い点:
- Browser-facing REST API は概ね `runtime_id + worker_id` routing になっており、worker_id-only route は見当たらない。
- remote command は Backend-owned `reqwest::blocking` REST client、observation は Backend-owned WS proxy/client 境界で実装されている。
- API/WS の通常 payload 型には remote base URL/token/direct endpoint は載っていない。
- auth/timeout/unsupported/remote error/worker-not-found は typed diagnostic/error に寄せる実装になっている。
- dynamic registration / full auth / WebConsole / Profile sync への scope creep は見当たらない。
- `reqwest::blocking` は async handler 内で Tokio worker を最大 timeout 分ブロックするリスクがあるが、v0 の同期 `RuntimeRegistry` 境界かつ timeout ありなら今回は許容可能な設計負債と判断する。
必要な修正:
- `RuntimeObservationSourceConfig` と、それを内包する debug/log-worthy 型の `Debug` を手書き redaction にするか derive を外す。
- base URL / endpoint / token が `Debug` 出力に含まれない focused test を追加する。
検証: read-only で対象 commit/diff/source/tests を確認。テスト実行は未実施。
---
<!-- event: implementation_report author: yoi-coder-00001KVZSGT14-remote-runtime at: 2026-06-26T06:27:50Z -->
## Implementation report
Follow-up implementation report after review request_changes:
Commit:
- `38ff7d8f807256ec654fcf5655e04dcbad4d9041 fix: redact observation source debug output`
修正内容:
- `RuntimeObservationSourceConfig``Debug` derive を廃止し、手動 `Debug` 実装に変更。
- `runtime_id` / `worker_id` は残す。
- `endpoint``<backend-private>`
- `bearer_token``<redacted>`
- `BackendObservationProxy``Debug` derive を廃止し、手動 `Debug` 実装に変更。
- source map / state の中身は出さず、`source_count` と omitted state のみ表示。
- focused test を追加。
- source config の Debug に remote endpoint/base URL/path/token が含まれないこと。
- containing proxy の Debug に remote endpoint/base URL/path/token が含まれないこと。
Validation:
- `cargo fmt --all`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- 今回の修正対象だった Debug/log-worthy 経路の漏洩は塞いだ。
- `RuntimeObservationSourceConfig` 自体は Backend 内部で real endpoint/token を保持し続ける。これは remote WS proxy 実行に必要な内部保持であり、API/WS payload には出していない。
- 将来 `Display` や手動ログで field を直接出す実装を追加する場合は別途注意が必要。
---
<!-- event: review author: yoi-reviewer-00001KVZSGT14-remote-runtime at: 2026-06-26T06:29:12Z status: approve -->
## Review: approve
Review result: approve
ブロッカーは解消済み。
確認内容:
- `RuntimeObservationSourceConfig` は manual `Debug` になり、`runtime_id` / `worker_id` のみ実値、`endpoint` は `<backend-private>`、`bearer_token` は `<redacted>` 表示になっている。
- `BackendObservationProxy` も manual `Debug` になり、source map / state internals を出さず `source_count` と omitted marker だけを出すため、内包する remote endpoint/token が Debug 経由で漏れない。
- 追加テストは source config と proxy Debug の forbidden host/path/token 非混入を検証している。
- 差分は `observation.rs` の redaction/test に限定され、API/WS payload 形状や remote routing への scope creep は見当たらない。
検証:
- read-only inspection のみ実施。テスト実行はしていない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T06:29:53Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KVZSGT14-remote-runtime-registry` を orchestration branch に merge 済み。
- Merge commit: `bbb5d68c merge: 00001KVZSGT14 remote runtime registry`
Included implementation commits:
- `aeb12b3b8e16a948e6f8387f6fb50fec60c53cc4 feat: add remote runtime registry source`
- `38ff7d8f807256ec654fcf5655e04dcbad4d9041 fix: redact observation source debug output`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success33 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。前回 blocker だった Debug/log-worthy endpoint/token redaction は解消済み。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T06:30:04Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Backend RuntimeRegistry remote worker-runtime process connection の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `bbb5d68c merge: 00001KVZSGT14 remote runtime registry`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo test -p yoi-workspace-server`: success33 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- Backend RuntimeRegistry に remote worker-runtime process source/handle を追加し、Backend-owned REST/WS client 経由で route/proxy できるようにした。
- Dynamic registration / full auth / Web Console / Profile config sync は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T06:30:50Z -->
## Implementation report
Cleanup report:
- Child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KVZSGT14-remote-runtime`
- `yoi-reviewer-00001KVZSGT14-remote-runtime`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KVZSGT14-remote-runtime-registry`
- Child implementation branch removed:
- `work/00001KVZSGT14-remote-runtime-registry`
Operational note:
- StopPod was executed sequentially, not in parallel.
--- ---

View File

@ -0,0 +1,5 @@
{"id":"orch-plan-20260625-203613-1","ticket_id":"00001KW04A8K6","kind":"blocked_by","related_ticket":"00001KVZSGT14","note":"Queue routing checked. TUI migration to Runtime API/WebSocket depends on remote/backend runtime connection and WebSocket observation proxy foundation. Those dependencies are not done; keep this Ticket queued.","author":"yoi-orchestrator","at":"2026-06-25T20:36:13Z"}
{"id":"orch-plan-20260626-054937-2","ticket_id":"00001KW04A8K6","kind":"blocked_by","related_ticket":"00001KVZSGT14","note":"TUI migration depends on remote Runtime process connection `00001KVZSGT14`, which is now inprogress. Keep queued until remote source routing is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-26T05:49:37Z"}
{"id":"orch-plan-20260626-063414-3","ticket_id":"00001KW04A8K6","kind":"waiting_capacity_note","note":"TUI migration is left queued while Profile/config bundle sync `00001KVZQHPNY` is accepted/inprogress. Runtime worker creation/profile semantics are still being finalized; start after bundle sync branch is reviewed/merged/done to avoid API churn.","author":"yoi-orchestrator","at":"2026-06-26T06:34:14Z"}
{"id":"orch-plan-20260626-074213-4","ticket_id":"00001KW04A8K6","kind":"waiting_capacity_note","note":"TUI Runtime API/WebSocket migration is left queued while Web Console MVP `00001KVZ9JGK0` is accepted/inprogress. Both are UI/control-surface consumers of the new Runtime APIs; start TUI migration after Web Console branch is reviewed/merged/done to avoid API/UX churn.","author":"yoi-orchestrator","at":"2026-06-26T07:42:13Z"}
{"id":"orch-plan-20260626-080943-5","ticket_id":"00001KW04A8K6","kind":"accepted_plan","note":"All dependencies are now done: WebSocket proxy, Registry foundation, embedded/remote Runtime connections, REST command server, and Web Console/config bundle foundation. No active inprogress remains.","accepted_plan":{"summary":"TUI connection backend を旧 socket authority から Backend Runtime API / WebSocket observation stream へ移行する。既存 Console rendering/composer/status を活かし、Runtime event adapter、input command path、cursor/reconnect diagnostics、legacy debug/compat path separation を実装する。","branch":"work/00001KW04A8K6-tui-runtime-api","worktree":"/home/hare/Projects/yoi/.worktree/00001KW04A8K6-tui-runtime-api","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に TUI/client/protocol/yoi CLI 関連 crate と必要最小 Cargo/package files の write scope を委譲する。reviewer Worker は read-only で Backend API authority、runtime_id+worker_id routing、legacy/debug path separation、event adapter correctness、credential/path non-leak、TUI regressions を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T08:09:43Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'TUIをRuntime API/WebSocket接続へ移行する' title: 'TUIをRuntime API/WebSocket接続へ移行する'
state: 'queued' state: 'done'
created_at: '2026-06-25T19:32:38Z' created_at: '2026-06-25T19:32:38Z'
updated_at: '2026-06-25T20:34:42Z' updated_at: '2026-06-26T08:55:31Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-25T20:34:42Z' queued_at: '2026-06-25T20:34:42Z'

View File

@ -30,4 +30,304 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-25T20:36:43Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Dashboard Queue 後に Ticket / relations / workspace state を確認した。
- 本 Ticket は Runtime API / WebSocket observation への TUI移行であり、Backend RuntimeRegistry foundation、embedded/remote Runtime routing、WebSocket observation proxy が前提。
- 現在 `00001KVZKSV6C` は inprogress、`00001KVZKSTJT` / `00001KVZSGT0Q` / `00001KVZSGT14` は queued/blocked。TUI migration を先に始めると transport/API の未確定部分を TUI 側で固定してしまうため開始しない。
Evidence checked:
- Ticket body: TUI connection model、input path、output/observation path、Runtime WebSocket / Backend proxy reliance、compatibility/debug path。
- Relations: outgoing dependencies include `00001KVZKSTJT`, `00001KVZKSV6C`, `00001KVZSGT0Q`, `00001KVZSGT14`
- Orchestration plan: blocker record `orch-plan-20260625-203613-1` を追加。
Next action:
- 本 Ticket は queued のまま待機。
- Backend RuntimeRegistry / embedded+remote Runtime / WS proxy chain が done になった後に再 routing する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T08:10:06Z -->
## Decision
Routing decision: implementation_ready
Reason:
- All blocking dependencies are done: WebSocket observation proxy, RuntimeRegistry foundation, embedded Runtime connection, remote Runtime connection, REST command server, config bundle sync, and Web Console MVP.
- Current `inprogress` is 0 and this is the only queued Ticket.
- Ticket body has clear connection model, input path, output/observation path, compatibility/migration boundaries, acceptance criteria, and validation requirements.
Evidence checked:
- Ticket body: TUI Runtime/Worker target identity, Backend API client model, input command path, WebSocket observation path, existing TUI model relation, compatibility/debug path, acceptance criteria。
- Relations: depends_on `00001KVZKSTJT`, `00001KVZKSV6C`, `00001KVZSGT0Q`, `00001KVZSGT14`; all are done. Related REST command server is done.
- Orchestration plan: accepted plan `orch-plan-20260626-080943-5` recorded.
- Workspace state: orchestration worktree clean; no spawned child Workers currently active.
IntentPacket:
Intent:
- TUI の正規接続経路を Backend Runtime API / WebSocket observation stream に移行し、`runtime_id + worker_id` を対象 identity として input/output/status を扱う。
Binding decisions / invariants:
- TUI は remote Runtime endpoint / token / raw socket path / raw session path を authority として扱わない。
- Backend RuntimeRegistry / routing / endpoint credential 管理を TUI 内部に実装しない。TUI は Backend API client として振る舞う。
- Legacy direct socket attach を残す場合は compatibility/debug path として明確に分離し、正規 path と混同しない naming/diagnostics にする。
- Runtime event adapter は既存 Console model へ変換するが、raw provider trace / raw full session log を authority にしない。
- Full auth/multi-user permission model、raw session storage migration、旧 socket protocol 完全互換は Non-goals。
Requirements / acceptance criteria:
- TUI が `runtime_id + worker_id` target で接続できる。
- Input は Backend/Runtime command API 経由で Worker に届く。
- Output/status/transcript update は Runtime/Backend-proxied WebSocket observation stream から受け取る。
- Runtime events が existing TUI Console model に変換され、user message / assistant output / status / error が表示される。
- Initial transcript/snapshot 相当を表示できる。
- Reconnect / cursor resume / duplicate event は基本実装、または typed diagnostic になる。
- Browser/remote Runtime credential/socket/session path を TUI が authority として扱わない。
- Focused TUI/adapter tests が追加される。
Implementation latitude:
- CLI flag/selector UX、Backend API client module placement、Runtime event to Console block adapter design、cursor/reconnect policy は Coder が既存 TUI architecture に合わせて選べる。
- v0 は Backend API が提供する known Runtime/Worker projection に合わせ、dogfoodingに必要な legacy compatibility/debug modeを明示的に残してよい。
- Existing rendering/composer/status components は可能な範囲で再利用。
Escalate if:
- TUI に Runtime endpoint/token/socket/session path を直接渡す必要が出る。
- Backend API/WS が TUI migration に不足し、server foundation の大幅追加が必要になる。
- Existing Console rendering semantics を大きく削る必要がある。
- Pseudo-runtime adapter で userに実 runtime接続と誤認させる必要が出る。
Validation:
- `cargo fmt --all`
- `cargo test -p tui` または該当 TUI crate tests
- `cargo check -p yoi`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Backend/Runtime credential/path leak。
- `worker_id`-only or legacy socket authority regression。
- Runtime event adapter dropping/duplicating transcript events。
- Cursor/reconnect duplicate handling。
- Legacy debug path accidentally becoming the default path。
- Terminal UI regressions that need real TTY/manual validation notes。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T08:10:15Z from: queued to: inprogress reason: routing_accepted_after_runtime_ui_foundation_done field: state -->
## State changed
Routing decision: implementation_ready。
WebSocket observation proxy、RuntimeRegistry foundation、embedded/remote Runtime connection、REST command server、config bundle sync、Web Console MVP は done。Ticket body / relations / workspace state / accepted plan を確認し、TUI Runtime API / WebSocket migration は unblocked と判断した。accepted plan と IntentPacket は thread / orchestration plan に記録済み。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T08:11:15Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW04A8K6-tui-runtime-api`
- Branch: `work/00001KW04A8K6-tui-runtime-api`
- Coder Worker spawned: `yoi-coder-00001KW04A8K6-tui-runtime`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/tui/**`, `crates/client/**`, `crates/protocol/**`, `crates/yoi/**`, `crates/workspace-server/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Queue status:
- This was the last queued Ticket in the current chain; no other queued Ticket remains pending in this acceptance pass.
---
<!-- event: implementation_report author: yoi-coder-00001KW04A8K6-tui-runtime at: 2026-06-26T08:43:52Z -->
## Implementation report
Implementation report:
実装完了。merge/close/cleanup は行っていない。
Commit:
- `63ec9f95 feat: add backend runtime console target`
変更ファイル:
- `Cargo.lock`
- `crates/client/Cargo.toml`
- `crates/client/src/backend_runtime.rs`
- `crates/client/src/lib.rs`
- `crates/tui/src/console/mod.rs`
- `crates/tui/src/lib.rs`
- `crates/yoi/src/main.rs`
- `package.nix`
実装概要:
- `client` crate に Backend Runtime API 用クライアントを追加。
- authority は `Backend API base URL + runtime_id + worker_id`
- Runtime endpoint / token / raw socket path / raw session path は TUI 側の authority として扱っていない。
- `POST /api/runtimes/{runtime_id}/workers/{worker_id}/input` で user/system input を送信。
- `POST .../cancel` / `POST .../stop` で cancel/stop を送信。
- `GET .../transcript` から初期 transcript 相当を TUI event に変換。
- `WS .../events/ws` で Backend-proxied Runtime observation stream を購読。
- cursor resume / duplicate sequence drop / cursor expiry diagnostic + reset/reconnect を実装。
- Backend diagnostic / unsupported command / HTTP decode/send failure は TUI に `Event::Error` として表示。
- TUI Console に Backend Runtime 接続モードを追加。
- `LaunchMode::BackendRuntime { target }`
- 既存 Console rendering/composer/status の `App::handle_worker_event``protocol::Event` を流して再利用。
- legacy socket path は `ConsoleConnection::LegacySocket` として明示分離。
- CLI に Backend Runtime target selector を追加。
- `--backend <URL>`
- `--runtime-id <ID>` / alias `--runtime <ID>`
- `--worker-id <ID>`
- 3つは必須セット。
- `--worker` / `--socket` / `--session` / `--profile` との混在を拒否。
- help 上で `--socket` は compatibility/debug direct socket attach と明記。
- Focused tests を追加。
- Backend command mapping。
- Backend WS URL generation using `runtime_id + worker_id`
- Transcript projection → TUI Console event sequence。
- CLI Backend Runtime target parsing / incomplete identity rejection / legacy option mix rejection。
Validation:
- `cargo fmt --all`: success
- `cargo test -p tui`: success382 passed
- `cargo test -p yoi parse_backend_runtime_target`: success3 passed
- `cargo test -p client backend_runtime::tests`: success3 passed
- `cargo check -p yoi`: success
- `git diff --check` / `git diff HEAD --check`: success
- `nix build .#yoi --no-link`: success
Additional note:
- `cargo test -p client` 全体も一度実行したが、既存の `ticket_role::tests::scaffold_config_allows_intake_and_orchestrator_launch_planning` が Lua builtin profile 評価エラーで失敗。今回追加した `backend_runtime::tests` は個別に成功。
Residual risks / review focus:
- Backend Runtime mode は既存 Backend endpoint 形状に合わせた薄い API client 実装で、Runtime routing / endpoint credential management は TUI 側に入れていない。
- pause/resume/compact/rewind/completion lookup 等、Backend API にまだ対応 endpoint がない TUI commands は diagnostic 表示のみで送信しない。
- 初期 transcript は Backend transcript projection から user/assistant chat event に変換。raw session log / provider trace は authority として使っていない。
- default local spawn / legacy Worker-name attach 経路は既存互換として残る。Backend Runtime target 経路は `--backend + --runtime-id + --worker-id` による明示接続。
---
<!-- event: review author: yoi-reviewer-00001KW04A8K6-tui-runtime at: 2026-06-26T08:52:42Z status: approve -->
## Review: approve
Review result: approve
確認結果として、Ticket の主要要件は満たしていると判断する。
確認内容:
- Backend API authority:
- TUI 側は `backend_url + runtime_id + worker_id``BackendRuntimeTarget` を authority とし、Runtime endpoint/token/raw socket/session path を扱っていない。
- Backend/Runtime Registry/credential routing は TUI 内に実装されておらず、Backend API client として分離されている。
- `runtime_id + worker_id`:
- CLI は `--backend` に加えて `--runtime-id`/`--runtime` と `--worker-id` を必須化しており、worker_id-only 回帰は見当たらない。
- legacy options との mixing reject も確認。
- command / observation:
- input は Backend API の `/input`、cancel/stop は lifecycle API 経由。
- transcript 初期投影 + Backend-proxied WS observation stream を Console 既存 model に流す構成。
- `UserMessage` / assistant text delta/done / status / error は既存 `protocol::Event` として扱われ、Console rendering/composer/status を再利用している。
- reconnect/cursor:
- sequence duplicate drop、cursor resume、cursor expired diagnostic + reset/reconnect が実装されている。
- unsupported command は diagnostic event として返す実装を確認。
- legacy/debug path:
- raw `--socket` は explicit path として残り、BackendRuntime mode とは混在不可。
- help 文面上は `--socket` が compatibility/debug であることや backend options が十分に目立っていないため後続改善余地はあるが、今回の機能成立を阻害する blocker ではない。
検証実施:
- `cargo test -p yoi parse_backend_runtime_target`: success
- `cargo test -p client backend_runtime::tests`: success
- `cargo test -p tui`: success
- `cargo check -p yoi`: success
- `git diff --check HEAD^..HEAD`: success
報告されている `cargo test -p client` full の既存 Lua builtin profile failure は、この差分の focused validation 上は blocker ではないと判断する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T08:54:28Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KW04A8K6-tui-runtime-api` を orchestration branch に merge 済み。
- Merge commit: `0a683bb2 merge: 00001KW04A8K6 tui runtime migration`
Included implementation commit:
- `63ec9f95 feat: add backend runtime console target`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo test -p tui`: success382 tests passed
- `cargo test -p yoi parse_backend_runtime_target`: success3 tests passed
- `cargo test -p client backend_runtime::tests`: success3 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。Backend API authority、`runtime_id + worker_id` target、event adapter、cursor/reconnect diagnostic、legacy/debug path separation に blocker なし。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T08:54:39Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
TUI Runtime API / WebSocket migration の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `0a683bb2 merge: 00001KW04A8K6 tui runtime migration`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo test -p tui`: success382 tests passed
- `cargo test -p yoi parse_backend_runtime_target`: success3 tests passed
- `cargo test -p client backend_runtime::tests`: success3 tests passed
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- Backend Runtime API / Backend-proxied WebSocket observation stream を TUI Console target として追加。
- Legacy direct socket attach は compatibility/debug path として明示分離。
- Full auth/multi-user permission model / raw session migration / perfect legacy socket compatibility は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T08:55:31Z -->
## Implementation report
Cleanup report:
- Child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KW04A8K6-tui-runtime`
- `yoi-reviewer-00001KW04A8K6-tui-runtime`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KW04A8K6-tui-runtime-api`
- Child implementation branch removed:
- `work/00001KW04A8K6-tui-runtime-api`
Operational note:
- StopPod was executed sequentially, not in parallel.
--- ---

65
Cargo.lock generated
View File

@ -203,6 +203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"base64",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -221,8 +222,10 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sha1",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-tungstenite 0.29.0",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@ -468,13 +471,17 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
name = "client" name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"futures",
"manifest", "manifest",
"protocol", "protocol",
"reqwest",
"serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
"tokio", "tokio",
"tokio-tungstenite 0.29.0",
"uuid", "uuid",
] ]
@ -1065,7 +1072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -2536,7 +2543,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -3487,7 +3494,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -3544,7 +3551,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -4154,7 +4161,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -4422,7 +4429,19 @@ dependencies = [
"native-tls", "native-tls",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tungstenite", "tungstenite 0.28.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.29.0",
] ]
[[package]] [[package]]
@ -4709,6 +4728,22 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.4",
"sha1",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "type1-encoding-parser" name = "type1-encoding-parser"
version = "0.1.1" version = "0.1.1"
@ -5463,7 +5498,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -5889,11 +5924,11 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite 0.28.0",
"toml", "toml",
"tools", "tools",
"tracing", "tracing",
"tungstenite", "tungstenite 0.28.0",
"uuid", "uuid",
"wasmtime", "wasmtime",
"wat", "wat",
@ -5905,9 +5940,16 @@ dependencies = [
name = "worker-runtime" name = "worker-runtime"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum",
"futures",
"protocol",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.29.0",
"tower",
] ]
[[package]] [[package]]
@ -5994,9 +6036,12 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"chrono", "chrono",
"futures",
"manifest", "manifest",
"pod-store", "pod-store",
"project-record", "project-record",
"protocol",
"reqwest",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
@ -6006,10 +6051,12 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"ticket", "ticket",
"tokio", "tokio",
"tokio-tungstenite 0.29.0",
"toml", "toml",
"tower", "tower",
"tracing", "tracing",
"uuid", "uuid",
"worker-runtime",
] ]
[[package]] [[package]]

View File

@ -104,6 +104,7 @@ sha2 = "0.11"
tempfile = "3.27" tempfile = "3.27"
thiserror = "2.0" thiserror = "2.0"
tokio = "1.52" tokio = "1.52"
tokio-tungstenite = "0.29"
tower = "0.5" tower = "0.5"
toml = "1.1" toml = "1.1"
tracing = "0.1" tracing = "0.1"

View File

@ -8,9 +8,13 @@ license.workspace = true
protocol = { workspace = true } protocol = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
futures = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
tokio-tungstenite = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]

View File

@ -0,0 +1,694 @@
use std::collections::VecDeque;
use std::fmt;
use std::time::Duration;
use futures::StreamExt;
use protocol::{ErrorCode, Event, Greeting, InFlightSnapshot, Method, Segment, WorkerStatus};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
const TRANSCRIPT_SNAPSHOT_LIMIT: usize = 512;
const RECONNECT_DELAY: Duration = Duration::from_millis(500);
const MAX_RECONNECT_ATTEMPTS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRuntimeTarget {
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
pub base_url: String,
/// Backend-owned Runtime identity used as path authority.
pub runtime_id: String,
/// Backend-owned Worker identity used as path authority.
pub worker_id: String,
}
impl BackendRuntimeTarget {
pub fn new(
base_url: impl Into<String>,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Self {
Self {
base_url: base_url.into(),
runtime_id: runtime_id.into(),
worker_id: worker_id.into(),
}
}
pub fn display_label(&self) -> String {
format!("{}:{}", self.runtime_id, self.worker_id)
}
}
#[derive(Debug)]
pub struct BackendRuntimeClient {
target: BackendRuntimeTarget,
http: reqwest::Client,
events: mpsc::UnboundedReceiver<Event>,
diagnostics: VecDeque<Event>,
_observation_task: tokio::task::JoinHandle<()>,
}
#[derive(Debug)]
pub enum BackendRuntimeClientError {
InvalidTarget(String),
Http(reqwest::Error),
}
impl fmt::Display for BackendRuntimeClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidTarget(message) => f.write_str(message),
Self::Http(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for BackendRuntimeClientError {}
impl From<reqwest::Error> for BackendRuntimeClientError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
}
}
impl BackendRuntimeClient {
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
validate_target(&target)?;
let http = reqwest::Client::new();
let (tx, rx) = mpsc::unbounded_channel();
let suppress_initial_snapshot = match load_initial_transcript(&http, &target).await {
Ok(events) => {
for event in events {
let _ = tx.send(event);
}
true
}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend initial transcript unavailable for {}: {error}",
target.display_label()
)));
false
}
};
let observation_target = target.clone();
let observation_tx = tx.clone();
let observation_task = tokio::spawn(async move {
observe_worker_events(
observation_target,
observation_tx,
suppress_initial_snapshot,
)
.await;
});
Ok(Self {
target,
http,
events: rx,
diagnostics: VecDeque::new(),
_observation_task: observation_task,
})
}
pub fn try_next_event(&mut self) -> Option<Event> {
if let Some(event) = self.diagnostics.pop_front() {
return Some(event);
}
self.events.try_recv().ok()
}
pub async fn next_event(&mut self) -> Option<Event> {
if let Some(event) = self.diagnostics.pop_front() {
return Some(event);
}
self.events.recv().await
}
pub async fn send(&mut self, method: &Method) -> Result<(), BackendRuntimeClientError> {
match backend_command_from_method(method) {
BackendCommand::Input { kind, content } => {
let url = self.worker_api_url("input");
match self
.http
.post(url)
.json(&WorkerInputRequest { kind, content })
.send()
.await
.and_then(|response| response.error_for_status())
{
Ok(response) => match response.json::<WorkerInputResult>().await {
Ok(result) => self.enqueue_operation_diagnostics(
"input",
result.state,
result.diagnostics,
),
Err(error) => self.enqueue_diagnostic(format!(
"Backend runtime input response could not be decoded for {}: {error}",
self.target.display_label()
)),
},
Err(error) => self.enqueue_diagnostic(format!(
"Backend runtime input failed for {}: {error}",
self.target.display_label()
)),
}
}
BackendCommand::Lifecycle { action, reason } => {
let url = self.worker_api_url(action);
match self
.http
.post(url)
.json(&WorkerLifecycleRequest { reason })
.send()
.await
.and_then(|response| response.error_for_status())
{
Ok(response) => match response.json::<WorkerLifecycleResult>().await {
Ok(result) => self.enqueue_operation_diagnostics(
action,
result.state,
result.diagnostics,
),
Err(error) => self.enqueue_diagnostic(format!(
"Backend runtime {action} response could not be decoded for {}: {error}",
self.target.display_label()
)),
},
Err(error) => self.enqueue_diagnostic(format!(
"Backend runtime {action} failed for {}: {error}",
self.target.display_label()
)),
}
}
BackendCommand::Unsupported(message) => {
self.enqueue_diagnostic(message);
}
}
Ok(())
}
fn worker_api_url(&self, suffix: &str) -> String {
let path = format!(
"/api/runtimes/{}/workers/{}/{}",
path_segment_encode(&self.target.runtime_id),
path_segment_encode(&self.target.worker_id),
suffix
);
join_base_and_path(&self.target.base_url, &path)
}
fn enqueue_operation_diagnostics(
&mut self,
operation: &str,
state: String,
diagnostics: Vec<BackendDiagnostic>,
) {
if state != "accepted" {
self.enqueue_diagnostic(format!(
"Backend runtime {operation} was {state} for {}",
self.target.display_label()
));
}
for diagnostic in diagnostics {
self.enqueue_diagnostic(format!(
"Backend runtime {operation} diagnostic [{}]: {}",
diagnostic.code, diagnostic.message
));
}
}
fn enqueue_diagnostic(&mut self, message: impl Into<String>) {
self.diagnostics.push_back(diagnostic_event(message));
}
}
impl Drop for BackendRuntimeClient {
fn drop(&mut self) {
self._observation_task.abort();
}
}
#[derive(Debug, PartialEq, Eq)]
enum BackendCommand {
Input {
kind: WorkerInputKind,
content: String,
},
Lifecycle {
action: &'static str,
reason: Option<String>,
},
Unsupported(String),
}
fn backend_command_from_method(method: &Method) -> BackendCommand {
match method {
Method::Run { input } => BackendCommand::Input {
kind: WorkerInputKind::User,
content: Segment::flatten_to_text(input),
},
Method::Notify { message, .. } => BackendCommand::Input {
kind: WorkerInputKind::System,
content: message.clone(),
},
Method::Cancel => BackendCommand::Lifecycle {
action: "cancel",
reason: Some("requested from TUI Backend Runtime API client".to_string()),
},
Method::Shutdown => BackendCommand::Lifecycle {
action: "stop",
reason: Some("requested from TUI Backend Runtime API client".to_string()),
},
Method::Pause => BackendCommand::Unsupported(
"Backend Runtime API does not expose pause/resume for the TUI client yet; command was not sent".to_string(),
),
Method::Resume => BackendCommand::Unsupported(
"Backend Runtime API does not expose resume for the TUI client yet; command was not sent".to_string(),
),
Method::Compact => BackendCommand::Unsupported(
"Backend Runtime API does not expose compaction for the TUI client yet; command was not sent".to_string(),
),
Method::ListCompletions { .. } => BackendCommand::Unsupported(
"Backend Runtime API does not expose completion lookup for the TUI client yet".to_string(),
),
Method::ListRewindTargets | Method::RewindTo { .. } => BackendCommand::Unsupported(
"Backend Runtime API does not expose rewind controls for the TUI client yet; command was not sent".to_string(),
),
Method::ListWorkers | Method::RestoreWorker { .. } | Method::RegisterPeer { .. } => {
BackendCommand::Unsupported(
"Backend Runtime API worker-management controls are not available from this Console connection".to_string(),
)
}
Method::WorkerEvent(_) => BackendCommand::Unsupported(
"Backend Runtime API does not accept child Worker lifecycle events from this Console connection".to_string(),
),
}
}
async fn load_initial_transcript(
http: &reqwest::Client,
target: &BackendRuntimeTarget,
) -> Result<Vec<Event>, BackendRuntimeClientError> {
let path = format!(
"/api/runtimes/{}/workers/{}/transcript?start=0&limit={TRANSCRIPT_SNAPSHOT_LIMIT}",
path_segment_encode(&target.runtime_id),
path_segment_encode(&target.worker_id)
);
let response = http
.get(join_base_and_path(&target.base_url, &path))
.send()
.await?
.error_for_status()?;
let transcript: WorkerTranscriptProjection = response.json().await?;
Ok(transcript_projection_to_events(target, transcript))
}
fn transcript_projection_to_events(
target: &BackendRuntimeTarget,
transcript: WorkerTranscriptProjection,
) -> Vec<Event> {
let mut events = vec![Event::Snapshot {
entries: Vec::new(),
greeting: Greeting {
worker_name: target.worker_id.clone(),
cwd: String::new(),
provider: "backend-runtime-api".to_string(),
model: target.runtime_id.clone(),
scope_summary: "Backend Runtime API worker observation".to_string(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
in_flight: InFlightSnapshot { blocks: Vec::new() },
}];
for item in transcript.items {
match item.role.as_str() {
"user" => events.push(Event::UserMessage {
segments: vec![Segment::text(item.content)],
}),
"assistant" => {
events.push(Event::TextDelta {
text: item.content.clone(),
});
events.push(Event::TextDone { text: item.content });
}
role => events.push(Event::Alert(protocol::Alert {
level: protocol::AlertLevel::Warn,
source: protocol::AlertSource::Worker,
message: format!(
"Backend transcript item with role `{role}` is not rendered as chat content"
),
timestamp_ms: 0,
})),
}
}
for diagnostic in transcript.diagnostics {
events.push(diagnostic_event(format!(
"Backend transcript diagnostic [{}]: {}",
diagnostic.code, diagnostic.message
)));
}
events
}
async fn observe_worker_events(
target: BackendRuntimeTarget,
tx: mpsc::UnboundedSender<Event>,
mut suppress_next_snapshot: bool,
) {
let mut cursor: Option<String> = None;
let mut last_sequence = 0_u64;
let mut attempts = 0_usize;
loop {
let url = observation_ws_url(&target, cursor.as_deref());
match connect_async(&url).await {
Ok((mut ws, _)) => {
attempts = 0;
while let Some(frame) = ws.next().await {
match frame {
Ok(TungsteniteMessage::Text(text)) => {
match serde_json::from_str::<ClientWorkerEventWsFrame>(&text) {
Ok(ClientWorkerEventWsFrame::Event { envelope }) => {
if envelope.runtime_id != target.runtime_id
|| envelope.worker_id != target.worker_id
{
let _ = tx.send(diagnostic_event(format!(
"Backend observation frame target mismatch: got {}:{}, expected {}",
envelope.runtime_id,
envelope.worker_id,
target.display_label()
)));
continue;
}
if let Some(sequence) = decode_backend_cursor(&envelope.cursor)
{
if sequence <= last_sequence {
continue;
}
last_sequence = sequence;
} else {
let _ = tx.send(diagnostic_event(format!(
"Backend observation cursor was malformed: {}",
envelope.cursor
)));
}
cursor = Some(envelope.cursor.clone());
if suppress_next_snapshot
&& matches!(envelope.payload, Event::Snapshot { .. })
{
suppress_next_snapshot = false;
continue;
}
let _ = tx.send(envelope.payload);
}
Ok(ClientWorkerEventWsFrame::Diagnostic { diagnostic }) => {
let message = format!(
"Backend observation diagnostic [{}]: {}",
diagnostic.code, diagnostic.message
);
let _ = tx.send(diagnostic_event(message));
if diagnostic.code == "backend.cursor_unknown_or_expired" {
cursor = None;
last_sequence = 0;
break;
}
}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend observation frame was not valid JSON: {error}"
)));
}
}
}
Ok(TungsteniteMessage::Close(_)) => break,
Ok(TungsteniteMessage::Ping(_))
| Ok(TungsteniteMessage::Pong(_))
| Ok(TungsteniteMessage::Binary(_))
| Ok(TungsteniteMessage::Frame(_)) => {}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend observation WebSocket error for {}: {error}",
target.display_label()
)));
break;
}
}
}
}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend observation WebSocket connect failed for {}: {error}",
target.display_label()
)));
}
}
attempts += 1;
if attempts > MAX_RECONNECT_ATTEMPTS {
let _ = tx.send(diagnostic_event(format!(
"Backend observation stream for {} stopped after {MAX_RECONNECT_ATTEMPTS} reconnect attempts",
target.display_label()
)));
break;
}
tokio::time::sleep(RECONNECT_DELAY).await;
}
}
fn diagnostic_event(message: impl Into<String>) -> Event {
Event::Error {
code: ErrorCode::Internal,
message: message.into(),
}
}
fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> {
if target.base_url.trim().is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL is required".to_string(),
));
}
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
if target.runtime_id.is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"runtime_id is required".to_string(),
));
}
if target.worker_id.is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"worker_id is required".to_string(),
));
}
Ok(())
}
fn observation_ws_url(target: &BackendRuntimeTarget, cursor: Option<&str>) -> String {
let path = format!(
"/api/runtimes/{}/workers/{}/events/ws",
path_segment_encode(&target.runtime_id),
path_segment_encode(&target.worker_id)
);
let mut url = join_base_and_path(&http_base_to_ws(&target.base_url), &path);
if let Some(cursor) = cursor {
url.push_str("?cursor=");
url.push_str(&query_value_encode(cursor));
}
url
}
fn http_base_to_ws(base: &str) -> String {
if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = base.strip_prefix("http://") {
format!("ws://{rest}")
} else {
base.to_string()
}
}
fn join_base_and_path(base: &str, path: &str) -> String {
format!("{}{}", base.trim_end_matches('/'), path)
}
fn decode_backend_cursor(cursor: &str) -> Option<u64> {
let encoded = cursor.strip_prefix("bo_")?;
if encoded.len() != 16 {
return None;
}
u64::from_str_radix(encoded, 16).ok()
}
fn path_segment_encode(input: &str) -> String {
percent_encode(input, |byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
})
}
fn query_value_encode(input: &str) -> String {
percent_encode(input, |byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
})
}
fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
let mut encoded = String::with_capacity(input.len());
for byte in input.bytes() {
if keep(byte) {
encoded.push(byte as char);
} else {
encoded.push('%');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum WorkerInputKind {
User,
System,
}
#[derive(Debug, Serialize)]
struct WorkerInputRequest {
kind: WorkerInputKind,
content: String,
}
#[derive(Debug, Serialize)]
struct WorkerLifecycleRequest {
reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WorkerInputResult {
state: String,
#[serde(default)]
diagnostics: Vec<BackendDiagnostic>,
}
#[derive(Debug, Deserialize)]
struct WorkerLifecycleResult {
state: String,
#[serde(default)]
diagnostics: Vec<BackendDiagnostic>,
}
#[derive(Debug, Deserialize)]
struct BackendDiagnostic {
code: String,
message: String,
}
#[derive(Debug, Deserialize)]
struct WorkerTranscriptProjection {
#[serde(default)]
items: Vec<WorkerTranscriptItem>,
#[serde(default)]
diagnostics: Vec<BackendDiagnostic>,
}
#[derive(Debug, Deserialize)]
struct WorkerTranscriptItem {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum ClientWorkerEventWsFrame {
Event {
envelope: ClientWorkerEventWsEnvelope,
},
Diagnostic {
diagnostic: ClientWorkerEventWsDiagnostic,
},
}
#[derive(Debug, Deserialize)]
struct ClientWorkerEventWsEnvelope {
cursor: String,
runtime_id: String,
worker_id: String,
payload: Event,
}
#[derive(Debug, Deserialize)]
struct ClientWorkerEventWsDiagnostic {
code: String,
message: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_command_maps_run_to_user_input_without_runtime_endpoint() {
let method = Method::Run {
input: vec![
Segment::text("hello"),
Segment::FileRef {
path: "src/lib.rs".into(),
},
],
};
assert_eq!(
backend_command_from_method(&method),
BackendCommand::Input {
kind: WorkerInputKind::User,
content: "hello@src/lib.rs".to_string(),
}
);
}
#[test]
fn observation_url_uses_backend_runtime_worker_identity() {
let target =
BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one");
assert_eq!(
observation_ws_url(&target, Some("bo_0000000000000001")),
"ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/events/ws?cursor=bo_0000000000000001"
);
}
#[test]
fn transcript_projection_seeds_snapshot_and_chat_events() {
let target = BackendRuntimeTarget::new("http://backend", "runtime-a", "worker-b");
let events = transcript_projection_to_events(
&target,
WorkerTranscriptProjection {
items: vec![
WorkerTranscriptItem {
role: "user".to_string(),
content: "hi".to_string(),
},
WorkerTranscriptItem {
role: "assistant".to_string(),
content: "hello".to_string(),
},
],
diagnostics: Vec::new(),
},
);
assert!(matches!(events[0], Event::Snapshot { .. }));
assert!(matches!(events[1], Event::UserMessage { .. }));
assert!(matches!(events[2], Event::TextDelta { .. }));
assert!(matches!(events[3], Event::TextDone { .. }));
}
}

View File

@ -8,11 +8,13 @@
//! //!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
pub mod backend_runtime;
pub mod runtime_command; pub mod runtime_command;
pub mod spawn; pub mod spawn;
pub mod ticket_role; pub mod ticket_role;
mod worker_client; mod worker_client;
pub use backend_runtime::{BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeTarget};
pub use runtime_command::WorkerRuntimeCommand; pub use runtime_command::WorkerRuntimeCommand;
pub use spawn::{ pub use spawn::{

View File

@ -16,16 +16,16 @@ use crossterm::event::{
}; };
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{Command, execute}; use crossterm::{Command, execute};
use protocol::{Event, Method, WorkerStatus};
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
use protocol::{Method, WorkerStatus};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use session_store::SegmentId; use session_store::SegmentId;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use client::{WorkerClient, WorkerRuntimeCommand}; use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::composer_keys::{ComposerEditAction, composer_edit_action};
@ -171,6 +171,54 @@ pub(crate) async fn run_worker_name(
result result
} }
enum ConsoleConnection {
LegacySocket(WorkerClient),
BackendRuntime(BackendRuntimeClient),
}
impl ConsoleConnection {
fn try_next_event(&mut self) -> Option<Event> {
match self {
Self::LegacySocket(client) => client.try_next_event(),
Self::BackendRuntime(client) => client.try_next_event(),
}
}
async fn next_event(&mut self) -> Option<Event> {
match self {
Self::LegacySocket(client) => client.next_event().await,
Self::BackendRuntime(client) => client.next_event().await,
}
}
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
match self {
Self::LegacySocket(client) => Ok(client.send(method).await?),
Self::BackendRuntime(client) => Ok(client.send(method).await?),
}
}
}
pub(crate) async fn run_backend_runtime(
target: BackendRuntimeTarget,
) -> Result<(), Box<dyn std::error::Error>> {
let worker_label = target.display_label();
let client = BackendRuntimeClient::connect(target).await?;
let mut terminal = enter_fullscreen()?;
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
app.connected = true;
let result = run_loop(
&mut terminal,
&mut app,
ConsoleConnection::BackendRuntime(client),
None,
)
.await;
let _ = leave_fullscreen(&mut terminal);
result
}
async fn run_connected_pod( async fn run_connected_pod(
terminal: &mut ConsoleTerminal, terminal: &mut ConsoleTerminal,
worker_name: String, worker_name: String,
@ -180,7 +228,13 @@ async fn run_connected_pod(
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root); let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
app.connected = true; app.connected = true;
run_loop(terminal, &mut app, client, runtime_command).await run_loop(
terminal,
&mut app,
ConsoleConnection::LegacySocket(client),
Some(runtime_command),
)
.await
} }
pub(crate) async fn open_from_dashboard( pub(crate) async fn open_from_dashboard(
@ -396,7 +450,13 @@ async fn run(
app.connected = true; app.connected = true;
// The Worker sends `Event::Snapshot` automatically on connect; // The Worker sends `Event::Snapshot` automatically on connect;
// no explicit method call is required to fetch history. // no explicit method call is required to fetch history.
run_loop(terminal, &mut app, client, runtime_command).await?; run_loop(
terminal,
&mut app,
ConsoleConnection::LegacySocket(client),
Some(runtime_command),
)
.await?;
} }
Err(e) => { Err(e) => {
app.push_error(format!( app.push_error(format!(
@ -673,9 +733,9 @@ where
async fn drain_terminal_events( async fn drain_terminal_events(
app: &mut App, app: &mut App,
client: &mut WorkerClient, client: &mut ConsoleConnection,
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>, term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
runtime_command: &WorkerRuntimeCommand, runtime_command: Option<&WorkerRuntimeCommand>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
@ -701,7 +761,7 @@ async fn drain_terminal_events(
async fn drain_worker_events( async fn drain_worker_events(
app: &mut App, app: &mut App,
client: &mut WorkerClient, client: &mut ConsoleConnection,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..POD_EVENT_DRAIN_LIMIT { for _ in 0..POD_EVENT_DRAIN_LIMIT {
@ -721,8 +781,8 @@ async fn drain_worker_events(
async fn run_loop( async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App, app: &mut App,
mut client: WorkerClient, mut client: ConsoleConnection,
runtime_command: WorkerRuntimeCommand, runtime_command: Option<WorkerRuntimeCommand>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
@ -734,7 +794,7 @@ async fn run_loop(
} }
let handled_term_event = let handled_term_event =
drain_terminal_events(app, &mut client, &mut term_rx, &runtime_command).await?; drain_terminal_events(app, &mut client, &mut term_rx, runtime_command.as_ref()).await?;
if app.quit { if app.quit {
break; break;
} }
@ -746,7 +806,8 @@ async fn run_loop(
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
LoopInput::Terminal(term_event) => { LoopInput::Terminal(term_event) => {
handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?; handle_terminal_event(app, &mut client, term_event?, runtime_command.as_ref())
.await?;
} }
LoopInput::Worker(event) => match event { LoopInput::Worker(event) => match event {
Some(ev) => { Some(ev) => {
@ -770,9 +831,9 @@ async fn run_loop(
async fn handle_terminal_event( async fn handle_terminal_event(
app: &mut App, app: &mut App,
client: &mut WorkerClient, client: &mut ConsoleConnection,
event: TermEvent, event: TermEvent,
_runtime_command: &WorkerRuntimeCommand, _runtime_command: Option<&WorkerRuntimeCommand>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
match event { match event {
TermEvent::Key(key) => { TermEvent::Key(key) => {

View File

@ -33,7 +33,7 @@ use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
use session_store::SegmentId; use session_store::SegmentId;
use client::WorkerRuntimeCommand; use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LaunchOptions { pub struct LaunchOptions {
@ -55,6 +55,9 @@ pub enum LaunchMode {
worker_name: String, worker_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
}, },
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: connect through the
/// Workspace Backend Runtime API and observe the Backend-proxied event stream.
BackendRuntime { target: BackendRuntimeTarget },
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker /// `yoi resume`: open the Worker picker, then attach to the selected live Worker
/// or restore the selected stopped Worker by name. Without `--all`, the picker /// or restore the selected stopped Worker by name. Without `--all`, the picker
/// is scoped to the current runtime workspace. /// is scoped to the current runtime workspace.
@ -103,6 +106,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
worker_name, worker_name,
socket_override, socket_override,
} => console::run_worker_name(worker_name, socket_override, runtime_command).await, } => console::run_worker_name(worker_name, socket_override, runtime_command).await,
LaunchMode::BackendRuntime { target } => console::run_backend_runtime(target).await,
LaunchMode::Resume { all } => { LaunchMode::Resume { all } => {
console::run_resume(runtime_command, workspace_root.clone(), all).await console::run_resume(runtime_command, workspace_root.clone(), all).await
} }

View File

@ -5,11 +5,28 @@ version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
[[bin]]
name = "worker-runtime-rest-server"
path = "src/main.rs"
required-features = ["http-server"]
[features] [features]
default = [] default = []
fs-store = ["dep:serde_json"] 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"]
[dependencies] [dependencies]
axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
protocol = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
sha2.workspace = true
serde_json = { workspace = true, optional = true } serde_json = { workspace = true, optional = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt"], optional = true }
tower = { workspace = true, features = ["util"], optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tokio-tungstenite.workspace = true

View File

@ -40,10 +40,11 @@ impl Default for ProfileSelector {
} }
} }
/// Placeholder for future config-bundle synchronization. /// Backend-synced config bundle reference used during Worker creation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleRef { pub struct ConfigBundleRef {
pub id: String, pub id: String,
pub digest: String,
} }
/// Requested capability name plus optional human-readable reason. /// Requested capability name plus optional human-readable reason.

View File

@ -0,0 +1,519 @@
use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::error::RuntimeError;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256";
/// Backend-synced Profile/config bundle stored by a Runtime.
///
/// The bundle is intentionally an intent/declaration boundary: it contains
/// profile selectors plus refs/grants/policies, never secret values, direct
/// Runtime endpoints, raw socket/session paths, runtime-local mount actual
/// paths, host-local cache paths, or fully resolved WorkerSpec content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundle {
pub metadata: ConfigBundleMetadata,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub profiles: Vec<ConfigProfileDescriptor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declarations: Vec<ConfigDeclaration>,
}
impl ConfigBundle {
pub fn computed_digest(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("id\0{}", self.metadata.id));
lines.push(format!("revision\0{}", self.metadata.revision));
lines.push(format!("workspace_id\0{}", self.metadata.workspace_id));
lines.push(format!("created_at\0{}", self.metadata.created_at));
lines.push(format!(
"provenance.source\0{}",
self.metadata.provenance.source
));
lines.push(format!(
"provenance.detail\0{}",
self.metadata.provenance.detail.as_deref().unwrap_or("")
));
let mut profiles = self.profiles.clone();
profiles.sort_by(|left, right| {
profile_sort_key(&left.selector).cmp(&profile_sort_key(&right.selector))
});
for profile in profiles {
lines.push(format!(
"profile\0{}\0{}",
profile_sort_key(&profile.selector),
profile.label.unwrap_or_default()
));
}
let mut declarations = self.declarations.clone();
declarations
.sort_by(|left, right| declaration_sort_key(left).cmp(&declaration_sort_key(right)));
for declaration in declarations {
lines.push(format!(
"declaration\0{}\0{}\0{}",
declaration.kind.canonical_name(),
declaration.name,
declaration.reference
));
}
lines.sort();
let mut hasher = Sha256::new();
for line in lines {
hasher.update(line.as_bytes());
hasher.update(b"\n");
}
let digest = hasher.finalize();
hex_digest(&digest)
}
pub fn with_computed_digest(mut self) -> Self {
self.metadata.digest = self.computed_digest();
self
}
pub fn summary(&self) -> ConfigBundleSummary {
ConfigBundleSummary {
id: self.metadata.id.clone(),
digest: self.metadata.digest.clone(),
digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
revision: self.metadata.revision.clone(),
workspace_id: self.metadata.workspace_id.clone(),
created_at: self.metadata.created_at.clone(),
provenance: self.metadata.provenance.clone(),
profile_count: self.profiles.len(),
declaration_count: self.declarations.len(),
}
}
pub fn contains_profile(&self, selector: &ProfileSelector) -> bool {
self.profiles
.iter()
.any(|profile| profile.selector == *selector)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleMetadata {
pub id: String,
pub digest: String,
pub revision: String,
pub workspace_id: String,
pub created_at: String,
pub provenance: ConfigBundleProvenance,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleProvenance {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigProfileDescriptor {
pub selector: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigDeclaration {
pub kind: ConfigDeclarationKind,
pub name: String,
pub reference: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigDeclarationKind {
SecretRef,
MountGrant,
NetworkPolicy,
ShellPolicy,
GitPolicy,
CapabilityGrant,
Unsupported,
}
impl ConfigDeclarationKind {
pub fn canonical_name(&self) -> &'static str {
match self {
Self::SecretRef => "secret_ref",
Self::MountGrant => "mount_grant",
Self::NetworkPolicy => "network_policy",
Self::ShellPolicy => "shell_policy",
Self::GitPolicy => "git_policy",
Self::CapabilityGrant => "capability_grant",
Self::Unsupported => "unsupported",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleSummary {
pub id: String,
pub digest: String,
pub digest_algorithm: String,
pub revision: String,
pub workspace_id: String,
pub created_at: String,
pub provenance: ConfigBundleProvenance,
pub profile_count: usize,
pub declaration_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleAvailability {
pub reference: ConfigBundleRef,
pub summary: ConfigBundleSummary,
}
pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), RuntimeError> {
validate_config_bundle_id(&bundle.metadata.id)?;
validate_non_empty("config bundle digest", &bundle.metadata.digest)?;
validate_digest("config bundle digest", &bundle.metadata.digest)?;
validate_non_empty("config bundle revision", &bundle.metadata.revision)?;
validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?;
validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?;
validate_non_empty(
"config bundle provenance source",
&bundle.metadata.provenance.source,
)?;
validate_boundary_text("config bundle id", &bundle.metadata.id)?;
validate_boundary_text("config bundle revision", &bundle.metadata.revision)?;
validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?;
validate_boundary_text(
"config bundle provenance source",
&bundle.metadata.provenance.source,
)?;
if let Some(detail) = &bundle.metadata.provenance.detail {
validate_boundary_text("config bundle provenance detail", detail)?;
}
let computed = bundle.computed_digest();
if computed != bundle.metadata.digest {
return Err(RuntimeError::ConfigBundleDigestMismatch {
bundle_id: bundle.metadata.id.clone(),
expected_digest: bundle.metadata.digest.clone(),
actual_digest: computed,
});
}
for profile in &bundle.profiles {
validate_profile_selector(profile.selector.clone(), Some(&bundle.metadata.id))?;
if let Some(label) = &profile.label {
validate_boundary_text("profile label", label)?;
}
}
for declaration in &bundle.declarations {
validate_non_empty("config declaration name", &declaration.name)?;
validate_boundary_text("config declaration name", &declaration.name)?;
if declaration.kind == ConfigDeclarationKind::Unsupported {
return Err(RuntimeError::UnsupportedConfigDeclaration {
bundle_id: bundle.metadata.id.clone(),
declaration_kind: declaration.kind.canonical_name().to_string(),
name: declaration.name.clone(),
});
}
validate_declaration_reference(&bundle.metadata.id, declaration)?;
}
Ok(())
}
pub(crate) fn validate_config_bundle_ref(reference: &ConfigBundleRef) -> Result<(), RuntimeError> {
validate_config_bundle_id(&reference.id)?;
validate_non_empty("config bundle reference digest", &reference.digest)?;
validate_digest("config bundle reference digest", &reference.digest)?;
Ok(())
}
pub(crate) fn validate_profile_selector(
selector: ProfileSelector,
bundle_id: Option<&str>,
) -> Result<(), RuntimeError> {
match selector {
ProfileSelector::RuntimeDefault => Ok(()),
ProfileSelector::Builtin(value) | ProfileSelector::Named(value) => {
if value.trim().is_empty() {
Err(RuntimeError::InvalidProfileSelector {
profile: value,
bundle_id: bundle_id.map(ToOwned::to_owned),
message: "profile selector must not be empty".to_string(),
})
} else {
validate_boundary_text("profile selector", &value).map_err(|err| match err {
RuntimeError::InvalidRequest(message) => RuntimeError::InvalidProfileSelector {
profile: value,
bundle_id: bundle_id.map(ToOwned::to_owned),
message,
},
other => other,
})
}
}
}
}
fn validate_non_empty(label: &'static str, value: &str) -> Result<(), RuntimeError> {
if value.trim().is_empty() {
Err(RuntimeError::InvalidRequest(format!(
"{label} must not be empty"
)))
} else {
Ok(())
}
}
fn validate_config_bundle_id(value: &str) -> Result<(), RuntimeError> {
validate_non_empty("config bundle id", value)?;
let trimmed = value.trim();
if trimmed.len() > 128 {
return Err(RuntimeError::InvalidRequest(
"config bundle id is too large".to_string(),
));
}
if trimmed != value {
return Err(RuntimeError::InvalidRequest(
"config bundle id must not contain surrounding whitespace".to_string(),
));
}
if !trimmed
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
|| !trimmed
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
{
return Err(RuntimeError::InvalidRequest(
"config bundle id must be a path-safe stable identifier".to_string(),
));
}
Ok(())
}
fn validate_digest(label: &'static str, value: &str) -> Result<(), RuntimeError> {
let trimmed = value.trim();
if trimmed != value
|| trimmed.len() != 64
|| !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(RuntimeError::InvalidRequest(format!(
"{label} must be a 64-character lowercase sha256 hex digest"
)));
}
if !trimmed
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
return Err(RuntimeError::InvalidRequest(format!(
"{label} must be a 64-character lowercase sha256 hex digest"
)));
}
Ok(())
}
fn validate_declaration_reference(
bundle_id: &str,
declaration: &ConfigDeclaration,
) -> Result<(), RuntimeError> {
validate_non_empty("config declaration reference", &declaration.reference)?;
validate_ref_boundary_text("config declaration reference", &declaration.reference)?;
let allowed_prefixes: &[&str] = match declaration.kind {
ConfigDeclarationKind::SecretRef => &["secret:", "secret-ref:", "vault:", "keyring:"],
ConfigDeclarationKind::MountGrant => &["mount:", "mount-grant:"],
ConfigDeclarationKind::NetworkPolicy => &["network:", "network-policy:"],
ConfigDeclarationKind::ShellPolicy => &["shell:", "shell-policy:"],
ConfigDeclarationKind::GitPolicy => &["git:", "git-policy:"],
ConfigDeclarationKind::CapabilityGrant => &["capability:", "capability-grant:"],
ConfigDeclarationKind::Unsupported => &[],
};
if !allowed_prefixes.iter().any(|prefix| {
declaration.reference.starts_with(prefix) && declaration.reference.len() > prefix.len()
}) {
return Err(RuntimeError::UnsupportedConfigDeclaration {
bundle_id: bundle_id.to_string(),
declaration_kind: declaration.kind.canonical_name().to_string(),
name: declaration.name.clone(),
});
}
Ok(())
}
fn validate_ref_boundary_text(label: &'static str, value: &str) -> Result<(), RuntimeError> {
let trimmed = value.trim();
validate_boundary_text(label, trimmed)?;
if trimmed != value
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('?')
|| trimmed.contains('&')
|| trimmed.contains('#')
|| trimmed.contains('%')
|| trimmed.contains('=')
|| trimmed.chars().any(char::is_whitespace)
|| !trimmed.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'-' | b'_' | b'.' | b'@' | b'+')
})
{
return Err(RuntimeError::InvalidRequest(format!(
"{label} must be a typed ref/grant/policy token, not a secret value or path"
)));
}
let lower = trimmed.to_ascii_lowercase();
if lower.contains(".cache")
|| lower.contains(".yoi")
|| lower.contains(".sock")
|| lower.contains("socket=")
|| lower.contains("session_path")
|| lower.contains("cache_path")
{
return Err(RuntimeError::InvalidRequest(format!(
"{label} must not contain host-local cache/session/socket material"
)));
}
Ok(())
}
fn validate_boundary_text(label: &'static str, value: &str) -> Result<(), RuntimeError> {
let trimmed = value.trim();
if trimmed.len() > 2048 {
return Err(RuntimeError::InvalidRequest(format!(
"{label} is too large"
)));
}
if trimmed.chars().any(char::is_control) {
return Err(RuntimeError::InvalidRequest(format!(
"{label} must not contain control characters"
)));
}
let lower = trimmed.to_ascii_lowercase();
if trimmed.starts_with('/')
|| trimmed.starts_with('~')
|| trimmed.contains(":\\")
|| lower.contains(".cache")
|| lower.contains(".yoi/sessions")
|| lower.contains(".yoi\\sessions")
|| lower.contains("/sessions/")
|| lower.contains("\\sessions\\")
|| lower.contains("/run/")
|| lower.contains("\\run\\")
|| lower.contains(".sock")
|| lower.contains("/sock")
|| lower.contains("\\sock")
|| lower.contains("socket=")
|| lower.contains("session_path")
|| lower.contains("cache_path")
{
return Err(RuntimeError::InvalidRequest(format!(
"{label} must be a stable ref/grant/policy declaration, not a host-local path"
)));
}
Ok(())
}
fn declaration_sort_key(declaration: &ConfigDeclaration) -> String {
format!(
"{}\0{}\0{}",
declaration.kind.canonical_name(),
declaration.name,
declaration.reference
)
}
fn profile_sort_key(selector: &ProfileSelector) -> String {
match selector {
ProfileSelector::RuntimeDefault => "runtime_default".to_string(),
ProfileSelector::Builtin(value) => format!("builtin\0{value}"),
ProfileSelector::Named(value) => format!("named\0{value}"),
}
}
fn hex_digest(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn bundle_with_declaration(reference: &str) -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(),
digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance {
source: "test".to_string(),
detail: None,
},
},
profiles: vec![ConfigProfileDescriptor {
selector: ProfileSelector::Builtin("builtin:coder".to_string()),
label: None,
}],
declarations: vec![ConfigDeclaration {
kind: ConfigDeclarationKind::SecretRef,
name: "credential".to_string(),
reference: reference.to_string(),
}],
}
.with_computed_digest()
}
#[test]
fn rejects_host_local_cache_session_socket_and_plaintext_secret_refs() {
for reference in [
".cache/yoi",
".yoi/sessions/foo.jsonl",
"pods/foo/sock",
"password=hunter2",
"hunter2-secret-value",
] {
let error = validate_config_bundle(&bundle_with_declaration(reference)).unwrap_err();
assert!(
matches!(
error,
RuntimeError::InvalidRequest(_)
| RuntimeError::UnsupportedConfigDeclaration { .. }
),
"unexpected error for {reference}: {error:?}"
);
}
}
#[test]
fn accepts_typed_secret_refs() {
validate_config_bundle(&bundle_with_declaration("secret:github-token")).unwrap();
validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap();
}
#[test]
fn rejects_unsafe_bundle_ids_and_refs() {
for id in ["bundle/1", "bundle?x", "bundle&x", "bundle#x", " bundle"] {
let mut bundle = bundle_with_declaration("secret:github-token");
bundle.metadata.id = id.to_string();
bundle = bundle.with_computed_digest();
assert!(validate_config_bundle(&bundle).is_err(), "accepted id {id}");
}
assert!(
validate_config_bundle_ref(&ConfigBundleRef {
id: "bundle/1".to_string(),
digest: "0".repeat(64),
})
.is_err()
);
}
}

View File

@ -34,6 +34,34 @@ pub enum RuntimeError {
#[error("invalid request: {0}")] #[error("invalid request: {0}")]
InvalidRequest(String), InvalidRequest(String),
#[error("config bundle `{bundle_id}` was not found")]
ConfigBundleMissing { bundle_id: String },
#[error(
"config bundle `{bundle_id}` digest mismatch: expected {expected_digest}, got {actual_digest}"
)]
ConfigBundleDigestMismatch {
bundle_id: String,
expected_digest: String,
actual_digest: String,
},
#[error("invalid profile selector `{profile}` for config bundle {bundle_id:?}: {message}")]
InvalidProfileSelector {
profile: String,
bundle_id: Option<String>,
message: String,
},
#[error(
"config bundle `{bundle_id}` contains unsupported declaration `{declaration_kind}` named `{name}`"
)]
UnsupportedConfigDeclaration {
bundle_id: String,
declaration_kind: String,
name: String,
},
#[error("runtime store {operation} failed at {}: {source}", path.display())] #[error("runtime store {operation} failed at {}: {source}", path.display())]
StoreIo { StoreIo {
operation: &'static str, operation: &'static str,

View File

@ -1,4 +1,5 @@
use crate::catalog::{CreateWorkerRequest, WorkerStatus}; use crate::catalog::{CreateWorkerRequest, WorkerStatus};
use crate::config_bundle::ConfigBundle;
use crate::diagnostics::RuntimeDiagnostic; use crate::diagnostics::RuntimeDiagnostic;
use crate::error::RuntimeError; use crate::error::RuntimeError;
use crate::identity::{RuntimeId, WorkerId, WorkerRef}; use crate::identity::{RuntimeId, WorkerId, WorkerRef};
@ -364,6 +365,7 @@ pub(crate) struct PersistedRuntimeState {
pub(crate) next_event_id: u64, pub(crate) next_event_id: u64,
pub(crate) next_diagnostic_id: u64, pub(crate) next_diagnostic_id: u64,
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>, pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
pub(crate) config_bundles: BTreeMap<String, ConfigBundle>,
pub(crate) events: Vec<RuntimeEvent>, pub(crate) events: Vec<RuntimeEvent>,
pub(crate) diagnostics: Vec<RuntimeDiagnostic>, pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
} }
@ -390,6 +392,8 @@ struct RuntimeSnapshot {
next_worker_sequence: u64, next_worker_sequence: u64,
next_event_id: u64, next_event_id: u64,
next_diagnostic_id: u64, next_diagnostic_id: u64,
#[serde(default)]
config_bundles: BTreeMap<String, ConfigBundle>,
diagnostics: Vec<RuntimeDiagnostic>, diagnostics: Vec<RuntimeDiagnostic>,
} }
@ -405,6 +409,7 @@ impl RuntimeSnapshot {
next_worker_sequence: state.next_worker_sequence, next_worker_sequence: state.next_worker_sequence,
next_event_id: state.next_event_id, next_event_id: state.next_event_id,
next_diagnostic_id: state.next_diagnostic_id, next_diagnostic_id: state.next_diagnostic_id,
config_bundles: state.config_bundles.clone(),
diagnostics: state.diagnostics.clone(), diagnostics: state.diagnostics.clone(),
} }
} }
@ -454,6 +459,7 @@ impl RuntimeSnapshot {
next_event_id: self.next_event_id, next_event_id: self.next_event_id,
next_diagnostic_id: self.next_diagnostic_id, next_diagnostic_id: self.next_diagnostic_id,
workers, workers,
config_bundles: self.config_bundles,
events, events,
diagnostics: self.diagnostics, diagnostics: self.diagnostics,
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,20 @@
//! Embedded Runtime domain API for Worker management. //! Embedded Runtime domain API for Worker management.
//! //!
//! `worker-runtime` intentionally stays independent from HTTP/WebSocket servers, //! `worker-runtime` keeps its core independent from HTTP/WebSocket servers,
//! provider execution, and the existing Worker host. Filesystem persistence is //! provider execution, and the existing Worker host. Filesystem persistence is
//! available only through the optional `fs-store` feature. The crate defines the //! available only through the optional `fs-store` feature, and the minimal REST
//! in-process Runtime authority surface that higher layers can later adapt into //! process adapter is available only through the optional `http-server` feature.
//! registries or web APIs. //! The crate defines the in-process Runtime authority surface that higher layers
//! can later adapt into registries or backend APIs.
pub mod catalog; pub mod catalog;
pub mod config_bundle;
pub mod diagnostics; pub mod diagnostics;
pub mod error; pub mod error;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub mod fs_store; pub mod fs_store;
#[cfg(feature = "http-server")]
pub mod http_server;
pub mod identity; pub mod identity;
pub mod interaction; pub mod interaction;
pub mod management; pub mod management;

View File

@ -0,0 +1,346 @@
//! Minimal 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.
use std::collections::VecDeque;
use std::env;
use std::error::Error;
use std::fmt;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use worker_runtime::http_server::{
RuntimeHttpServer, RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
};
use worker_runtime::identity::RuntimeId;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("worker-runtime-rest-server: {error}");
if let ProcessError::Usage(_) = error {
eprintln!();
eprintln!("{}", usage());
ExitCode::from(2)
} else {
ExitCode::FAILURE
}
}
}
}
fn run() -> Result<(), ProcessError> {
let Some(config) = parse_args(env::args().skip(1))? else {
println!("{}", usage());
return Ok(());
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()?;
runtime.block_on(async move {
let server = RuntimeHttpServer::bind(config).await?;
let local_addr = server.local_addr()?;
eprintln!(
"worker-runtime REST server listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
);
server.serve().await
})?;
Ok(())
}
fn parse_args<I, S>(args: I) -> Result<Option<RuntimeHttpServerConfig>, ProcessError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut config = RuntimeHttpServerConfig::default();
let mut store = StoreArg::Memory;
let mut args = args.into_iter().map(Into::into).collect::<VecDeque<_>>();
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--help" | "-h" => return Ok(None),
"--bind" => {
let value = take_value(&flag, inline_value, &mut args)?;
config.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(|| {
ProcessError::usage("--runtime-id must not be empty".to_string())
})?);
}
"--display-name" => {
config.display_name = Some(take_value(&flag, inline_value, &mut args)?);
}
"--store" => {
let value = take_value(&flag, inline_value, &mut args)?;
store = match value.as_str() {
"memory" => StoreArg::Memory,
"fs" | "fs-store" => StoreArg::Fs { root: None },
_ => {
return Err(ProcessError::usage(format!(
"unsupported --store `{value}`; expected `memory` or `fs`"
)));
}
};
}
"--fs-root" => {
let value = take_value(&flag, inline_value, &mut args)?;
store = StoreArg::Fs {
root: Some(PathBuf::from(value)),
};
}
"--local-token" => {
let value = take_value(&flag, inline_value, &mut args)?;
if value.is_empty() {
return Err(ProcessError::usage(
"--local-token must not be empty when provided".to_string(),
));
}
config.local_token = Some(value);
}
"--local-token-env" => {
let name = take_value(&flag, inline_value, &mut args)?;
let value = env::var(&name).map_err(|error| {
ProcessError::usage(format!(
"failed to read --local-token-env `{name}`: {error}"
))
})?;
if value.is_empty() {
return Err(ProcessError::usage(format!(
"--local-token-env `{name}` resolved to an empty value"
)));
}
config.local_token = Some(value);
}
"--max-transcript-projection-items" => {
config.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 =
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
}
_ => {
return Err(ProcessError::usage(format!("unknown argument `{flag}`")));
}
}
}
apply_store_selection(&mut config, store)?;
Ok(Some(config))
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), ProcessError> {
if !arg.starts_with('-') {
return Err(ProcessError::usage(format!(
"unexpected positional argument `{arg}`"
)));
}
if let Some((flag, value)) = arg.split_once('=') {
Ok((flag.to_string(), Some(value.to_string())))
} else {
Ok((arg, None))
}
}
fn take_value(
flag: &str,
inline_value: Option<String>,
args: &mut VecDeque<String>,
) -> Result<String, ProcessError> {
if let Some(value) = inline_value {
return Ok(value);
}
args.pop_front()
.ok_or_else(|| ProcessError::usage(format!("{flag} requires a value")))
}
fn parse_usize_flag(flag: &str, value: String) -> Result<usize, ProcessError> {
value
.parse::<usize>()
.map_err(|error| ProcessError::usage(format!("invalid {flag} value `{value}`: {error}")))
}
fn apply_store_selection(
config: &mut RuntimeHttpServerConfig,
store: StoreArg,
) -> Result<(), ProcessError> {
match store {
StoreArg::Memory => {
config.store = RuntimeHttpStoreSelection::Memory;
Ok(())
}
StoreArg::Fs { root } => apply_fs_store_selection(config, root),
}
}
#[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(())
}
#[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(),
))
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum StoreArg {
Memory,
Fs { root: Option<PathBuf> },
}
#[derive(Debug)]
enum ProcessError {
Usage(String),
Server(RuntimeHttpServerError),
Io(std::io::Error),
}
impl ProcessError {
fn usage(message: String) -> Self {
Self::Usage(message)
}
}
impl fmt::Display for ProcessError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage(message) => message.fmt(f),
Self::Server(error) => error.fmt(f),
Self::Io(error) => error.fmt(f),
}
}
}
impl Error for ProcessError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Usage(_) => None,
Self::Server(error) => Some(error),
Self::Io(error) => Some(error),
}
}
}
impl From<RuntimeHttpServerError> for ProcessError {
fn from(error: RuntimeHttpServerError) -> Self {
Self::Server(error)
}
}
impl From<std::io::Error> for ProcessError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
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\
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\
--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\
--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\
--max-event-batch-items <N> Override event batch limit\n\
-h, --help Show this help"
}
#[cfg(test)]
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());
}
}

View File

@ -93,3 +93,62 @@ pub struct RuntimeEventBatch {
pub events: Vec<RuntimeEvent>, pub events: Vec<RuntimeEvent>,
pub has_more: bool, pub has_more: bool,
} }
/// Runtime-local cursor for worker-scoped WebSocket observation.
#[cfg(feature = "ws-server")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct WorkerObservationCursor {
pub sequence: u64,
}
#[cfg(feature = "ws-server")]
impl WorkerObservationCursor {
pub const PREFIX: &'static str = "wo";
pub fn new(sequence: u64) -> Self {
Self { sequence }
}
pub fn zero() -> Self {
Self { sequence: 0 }
}
pub fn encode(self) -> String {
format!("{}_{:016x}", Self::PREFIX, self.sequence)
}
pub fn decode(value: &str) -> Option<Self> {
let encoded = value.strip_prefix("wo_")?;
if encoded.len() != 16 {
return None;
}
u64::from_str_radix(encoded, 16)
.ok()
.map(|sequence| Self { sequence })
}
}
/// One protocol event observed from a runtime Worker.
#[cfg(feature = "ws-server")]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorkerObservationEvent {
pub cursor: String,
pub event_id: String,
pub sequence: u64,
pub worker_ref: WorkerRef,
pub payload: protocol::Event,
}
#[cfg(feature = "ws-server")]
impl WorkerObservationEvent {
pub fn new(sequence: u64, worker_ref: WorkerRef, payload: protocol::Event) -> Self {
let cursor = WorkerObservationCursor::new(sequence).encode();
Self {
event_id: cursor.clone(),
cursor,
sequence,
worker_ref,
payload,
}
}
}

View File

@ -1,5 +1,10 @@
use crate::catalog::{ use crate::catalog::{
CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerStatus, WorkerSummary, ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
WorkerStatus, WorkerSummary,
};
use crate::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
validate_config_bundle_ref, validate_profile_selector,
}; };
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
use crate::error::RuntimeError; use crate::error::RuntimeError;
@ -16,9 +21,15 @@ use crate::observation::{
EventCursor, EventSubscription, EventSubscriptionMode, RuntimeEvent, RuntimeEventBatch, EventCursor, EventSubscription, EventSubscriptionMode, RuntimeEvent, RuntimeEventBatch,
RuntimeEventKind, TranscriptEntry, TranscriptProjection, TranscriptQuery, TranscriptRole, RuntimeEventKind, TranscriptEntry, TranscriptProjection, TranscriptQuery, TranscriptRole,
}; };
#[cfg(feature = "ws-server")]
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[cfg(feature = "ws-server")]
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
#[cfg(feature = "ws-server")]
use tokio::sync::broadcast;
static NEXT_RUNTIME_SEQUENCE: AtomicU64 = AtomicU64::new(1); static NEXT_RUNTIME_SEQUENCE: AtomicU64 = AtomicU64::new(1);
@ -122,6 +133,45 @@ impl Runtime {
Ok(self.lock()?.status) Ok(self.lock()?.status)
} }
/// Store a backend-synced Profile/config bundle for later Worker creation.
pub fn store_config_bundle(
&self,
bundle: ConfigBundle,
) -> Result<ConfigBundleAvailability, RuntimeError> {
validate_config_bundle(&bundle)?;
let mut state = self.lock()?;
state.ensure_running()?;
let reference = ConfigBundleRef {
id: bundle.metadata.id.clone(),
digest: bundle.metadata.digest.clone(),
};
let summary = bundle.summary();
state
.config_bundles
.insert(bundle.metadata.id.clone(), bundle);
state.persist_runtime_snapshot()?;
Ok(ConfigBundleAvailability { reference, summary })
}
/// List synced config bundles known to this Runtime.
pub fn list_config_bundles(&self) -> Result<Vec<ConfigBundleSummary>, RuntimeError> {
Ok(self
.lock()?
.config_bundles
.values()
.map(ConfigBundle::summary)
.collect())
}
/// Validate that a config bundle reference is present and digest-matched.
pub fn check_config_bundle(
&self,
reference: &ConfigBundleRef,
) -> Result<ConfigBundleAvailability, RuntimeError> {
let state = self.lock()?;
state.check_config_bundle_ref(reference)
}
/// Stop the Runtime. v0 keeps data readable after stop, but rejects new /// Stop the Runtime. v0 keeps data readable after stop, but rejects new
/// create/send/worker lifecycle mutations. /// create/send/worker lifecycle mutations.
pub fn stop_runtime(&self) -> Result<u64, RuntimeError> { pub fn stop_runtime(&self) -> Result<u64, RuntimeError> {
@ -155,6 +205,7 @@ impl Runtime {
let mut state = self.lock()?; let mut state = self.lock()?;
state.ensure_running()?; state.ensure_running()?;
validate_create_worker_request(&request)?; validate_create_worker_request(&request)?;
state.validate_worker_config_boundary(&request)?;
let worker_id = WorkerId::generated(state.next_worker_sequence); let worker_id = WorkerId::generated(state.next_worker_sequence);
state.next_worker_sequence += 1; state.next_worker_sequence += 1;
@ -395,6 +446,88 @@ impl Runtime {
}) })
} }
/// Cursor pointing after the current worker-scoped protocol observation event.
#[cfg(feature = "ws-server")]
pub fn worker_observation_cursor_now(
&self,
worker_ref: &WorkerRef,
) -> Result<WorkerObservationCursor, RuntimeError> {
let state = self.lock()?;
state.ensure_worker_ref(worker_ref)?;
let sequence = state
.observation_events
.iter()
.rev()
.find(|event| &event.worker_ref == worker_ref)
.map(|event| event.sequence)
.unwrap_or(0);
Ok(WorkerObservationCursor::new(sequence))
}
/// Build the current Worker Snapshot event used as the first observation frame.
#[cfg(feature = "ws-server")]
pub fn worker_observation_snapshot(
&self,
worker_ref: &WorkerRef,
) -> Result<protocol::Event, RuntimeError> {
let state = self.lock()?;
let _worker = state.worker(worker_ref)?;
Ok(protocol::Event::Snapshot {
entries: Vec::new(),
greeting: protocol::Greeting {
worker_name: worker_ref.worker_id.to_string(),
cwd: String::new(),
provider: "worker-runtime".to_string(),
model: "worker-runtime".to_string(),
scope_summary: "runtime worker observation".to_string(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
},
status: protocol::WorkerStatus::Idle,
in_flight: protocol::InFlightSnapshot { blocks: Vec::new() },
})
}
/// Replay retained worker-scoped protocol observation events after a cursor.
#[cfg(feature = "ws-server")]
pub fn read_worker_observation_events(
&self,
worker_ref: &WorkerRef,
cursor: WorkerObservationCursor,
) -> Result<Vec<WorkerObservationEvent>, RuntimeError> {
let state = self.lock()?;
state.ensure_worker_ref(worker_ref)?;
state.validate_worker_observation_cursor(worker_ref, cursor)?;
Ok(state
.observation_events
.iter()
.filter(|event| &event.worker_ref == worker_ref && event.sequence > cursor.sequence)
.cloned()
.collect())
}
/// Subscribe to live protocol observation events.
#[cfg(feature = "ws-server")]
pub fn subscribe_worker_observation(
&self,
) -> Result<broadcast::Receiver<WorkerObservationEvent>, RuntimeError> {
Ok(self.lock()?.observation_tx.subscribe())
}
/// Append a Worker protocol event to the observation bus.
#[cfg(feature = "ws-server")]
pub fn observe_worker_event(
&self,
worker_ref: &WorkerRef,
payload: protocol::Event,
) -> Result<WorkerObservationEvent, RuntimeError> {
let mut state = self.lock()?;
state.ensure_worker_ref(worker_ref)?;
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
Ok(event)
}
/// Snapshot current diagnostics. /// Snapshot current diagnostics.
pub fn diagnostics(&self) -> Result<Vec<RuntimeDiagnostic>, RuntimeError> { pub fn diagnostics(&self) -> Result<Vec<RuntimeDiagnostic>, RuntimeError> {
Ok(self.lock()?.diagnostics.clone()) Ok(self.lock()?.diagnostics.clone())
@ -463,8 +596,15 @@ struct RuntimeState {
next_event_id: u64, next_event_id: u64,
next_diagnostic_id: u64, next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>, workers: BTreeMap<WorkerId, WorkerRecord>,
config_bundles: BTreeMap<String, ConfigBundle>,
events: Vec<RuntimeEvent>, events: Vec<RuntimeEvent>,
diagnostics: Vec<RuntimeDiagnostic>, diagnostics: Vec<RuntimeDiagnostic>,
#[cfg(feature = "ws-server")]
next_observation_sequence: u64,
#[cfg(feature = "ws-server")]
observation_events: VecDeque<WorkerObservationEvent>,
#[cfg(feature = "ws-server")]
observation_tx: broadcast::Sender<WorkerObservationEvent>,
} }
impl RuntimeState { impl RuntimeState {
@ -480,8 +620,15 @@ impl RuntimeState {
next_event_id: 1, next_event_id: 1,
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
config_bundles: BTreeMap::new(),
events: Vec::new(), events: Vec::new(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
#[cfg(feature = "ws-server")]
next_observation_sequence: 1,
#[cfg(feature = "ws-server")]
observation_events: VecDeque::new(),
#[cfg(feature = "ws-server")]
observation_tx: broadcast::channel(256).0,
} }
} }
@ -503,8 +650,15 @@ impl RuntimeState {
next_event_id: 1, next_event_id: 1,
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
config_bundles: BTreeMap::new(),
events: Vec::new(), events: Vec::new(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
#[cfg(feature = "ws-server")]
next_observation_sequence: 1,
#[cfg(feature = "ws-server")]
observation_events: VecDeque::new(),
#[cfg(feature = "ws-server")]
observation_tx: broadcast::channel(256).0,
} }
} }
@ -552,6 +706,7 @@ impl RuntimeState {
next_event_id: persisted.next_event_id, next_event_id: persisted.next_event_id,
next_diagnostic_id: persisted.next_diagnostic_id, next_diagnostic_id: persisted.next_diagnostic_id,
workers, workers,
config_bundles: persisted.config_bundles,
events: persisted.events, events: persisted.events,
diagnostics: persisted.diagnostics, diagnostics: persisted.diagnostics,
}) })
@ -572,6 +727,7 @@ impl RuntimeState {
.iter() .iter()
.map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record())) .map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record()))
.collect(), .collect(),
config_bundles: self.config_bundles.clone(),
events: self.events.clone(), events: self.events.clone(),
diagnostics: self.diagnostics.clone(), diagnostics: self.diagnostics.clone(),
} }
@ -704,6 +860,65 @@ impl RuntimeState {
} }
} }
fn check_config_bundle_ref(
&self,
reference: &ConfigBundleRef,
) -> Result<ConfigBundleAvailability, RuntimeError> {
validate_config_bundle_ref(reference)?;
let bundle = self.config_bundles.get(&reference.id).ok_or_else(|| {
RuntimeError::ConfigBundleMissing {
bundle_id: reference.id.clone(),
}
})?;
if bundle.metadata.digest != reference.digest {
return Err(RuntimeError::ConfigBundleDigestMismatch {
bundle_id: reference.id.clone(),
expected_digest: reference.digest.clone(),
actual_digest: bundle.metadata.digest.clone(),
});
}
Ok(ConfigBundleAvailability {
reference: reference.clone(),
summary: bundle.summary(),
})
}
fn validate_worker_config_boundary(
&self,
request: &CreateWorkerRequest,
) -> Result<(), RuntimeError> {
match &request.config_bundle {
Some(reference) => {
let availability = self.check_config_bundle_ref(reference)?;
let bundle = self
.config_bundles
.get(&availability.reference.id)
.ok_or_else(|| RuntimeError::ConfigBundleMissing {
bundle_id: availability.reference.id.clone(),
})?;
if !bundle.contains_profile(&request.profile) {
return Err(RuntimeError::InvalidProfileSelector {
profile: profile_label(&request.profile),
bundle_id: Some(reference.id.clone()),
message: "profile selector is not declared by synced config bundle"
.to_string(),
});
}
Ok(())
}
None => match &request.profile {
ProfileSelector::RuntimeDefault | ProfileSelector::Builtin(_) => {
validate_profile_selector(request.profile.clone(), None)
}
ProfileSelector::Named(_) => Err(RuntimeError::InvalidProfileSelector {
profile: profile_label(&request.profile),
bundle_id: None,
message: "named profiles require a synced config bundle reference".to_string(),
}),
},
}
}
fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> { fn ensure_worker_ref(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
if worker_ref.runtime_id != self.runtime_id { if worker_ref.runtime_id != self.runtime_id {
return Err(RuntimeError::WrongRuntime { return Err(RuntimeError::WrongRuntime {
@ -762,6 +977,54 @@ impl RuntimeState {
self.next_event_id.saturating_sub(1) self.next_event_id.saturating_sub(1)
} }
#[cfg(feature = "ws-server")]
fn validate_worker_observation_cursor(
&self,
worker_ref: &WorkerRef,
cursor: WorkerObservationCursor,
) -> Result<(), RuntimeError> {
if let Some(first) = self
.observation_events
.iter()
.find(|event| &event.worker_ref == worker_ref)
{
if cursor.sequence != 0 && cursor.sequence < first.sequence {
return Err(RuntimeError::InvalidRequest(format!(
"worker observation cursor {} is expired for worker {}",
cursor.encode(),
worker_ref.worker_id
)));
}
}
if cursor.sequence >= self.next_observation_sequence {
return Err(RuntimeError::InvalidRequest(format!(
"worker observation cursor {} is unknown for worker {}",
cursor.encode(),
worker_ref.worker_id
)));
}
Ok(())
}
#[cfg(feature = "ws-server")]
fn push_worker_observation_event(
&mut self,
worker_ref: WorkerRef,
payload: protocol::Event,
) -> WorkerObservationEvent {
const MAX_OBSERVATION_BACKLOG: usize = 1024;
let sequence = self.next_observation_sequence;
self.next_observation_sequence += 1;
let event = WorkerObservationEvent::new(sequence, worker_ref, payload);
self.observation_events.push_back(event.clone());
while self.observation_events.len() > MAX_OBSERVATION_BACKLOG {
self.observation_events.pop_front();
}
let _ = self.observation_tx.send(event.clone());
event
}
fn push_diagnostic( fn push_diagnostic(
&mut self, &mut self,
severity: DiagnosticSeverity, severity: DiagnosticSeverity,
@ -858,6 +1121,14 @@ impl WorkerRecord {
} }
} }
fn profile_label(selector: &ProfileSelector) -> String {
match selector {
ProfileSelector::RuntimeDefault => "runtime_default".to_string(),
ProfileSelector::Builtin(value) => value.clone(),
ProfileSelector::Named(value) => value.clone(),
}
}
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> { fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
if let crate::catalog::WorkerIntent::Task { objective } = &request.intent { if let crate::catalog::WorkerIntent::Task { objective } = &request.intent {
if objective.trim().is_empty() { if objective.trim().is_empty() {
@ -889,6 +1160,10 @@ fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
mod tests { mod tests {
use super::*; use super::*;
use crate::catalog::{CapabilityRequest, ConfigBundleRef, ProfileSelector, WorkerIntent}; use crate::catalog::{CapabilityRequest, ConfigBundleRef, ProfileSelector, WorkerIntent};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
ConfigDeclarationKind, ConfigProfileDescriptor,
};
use crate::management::RuntimeLimits; use crate::management::RuntimeLimits;
fn task_request(objective: &str) -> CreateWorkerRequest { fn task_request(objective: &str) -> CreateWorkerRequest {
@ -897,15 +1172,48 @@ mod tests {
objective: objective.to_string(), objective: objective.to_string(),
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
config_bundle: Some(ConfigBundleRef { config_bundle: None,
id: "bundle-1".to_string(),
}),
requested_capabilities: vec![CapabilityRequest::named("read")], requested_capabilities: vec![CapabilityRequest::named("read")],
workspace_refs: Vec::new(), workspace_refs: Vec::new(),
mount_refs: Vec::new(), mount_refs: Vec::new(),
} }
} }
fn test_bundle() -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
id: "bundle-1".to_string(),
digest: String::new(),
revision: "rev-1".to_string(),
workspace_id: "workspace-1".to_string(),
created_at: "2026-06-26T00:00:00Z".to_string(),
provenance: ConfigBundleProvenance {
source: "workspace-backend".to_string(),
detail: Some("profile-sync".to_string()),
},
},
profiles: vec![ConfigProfileDescriptor {
selector: ProfileSelector::Builtin("builtin:coder".to_string()),
label: Some("Coder".to_string()),
}],
declarations: vec![ConfigDeclaration {
kind: ConfigDeclarationKind::CapabilityGrant,
name: "read".to_string(),
reference: "capability:read".to_string(),
}],
}
.with_computed_digest()
}
fn bundled_task_request(objective: &str, bundle: &ConfigBundle) -> CreateWorkerRequest {
let mut request = task_request(objective);
request.config_bundle = Some(ConfigBundleRef {
id: bundle.metadata.id.clone(),
digest: bundle.metadata.digest.clone(),
});
request
}
#[test] #[test]
fn create_list_and_detail_preserve_runtime_worker_authority() { fn create_list_and_detail_preserve_runtime_worker_authority() {
let runtime = Runtime::new_memory(); let runtime = Runtime::new_memory();
@ -913,7 +1221,7 @@ mod tests {
assert_eq!(detail.worker_ref.runtime_id, runtime.runtime_id().unwrap()); assert_eq!(detail.worker_ref.runtime_id, runtime.runtime_id().unwrap());
assert_eq!(detail.status, WorkerStatus::Running); assert_eq!(detail.status, WorkerStatus::Running);
assert!(detail.config_bundle.is_some()); assert!(detail.config_bundle.is_none());
let list = runtime.list_workers().unwrap(); let list = runtime.list_workers().unwrap();
assert_eq!(list.len(), 1); assert_eq!(list.len(), 1);
@ -925,6 +1233,73 @@ mod tests {
assert_eq!(fetched.intent, detail.intent); assert_eq!(fetched.intent, detail.intent);
} }
#[test]
fn synced_config_bundle_is_stored_checked_and_used_for_worker_creation() {
let runtime = Runtime::new_memory();
let bundle = test_bundle();
let availability = runtime.store_config_bundle(bundle.clone()).unwrap();
assert_eq!(availability.reference.id, "bundle-1");
assert_eq!(availability.reference.digest, bundle.metadata.digest);
let listed = runtime.list_config_bundles().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "bundle-1");
let checked = runtime
.check_config_bundle(&availability.reference)
.unwrap();
assert_eq!(checked.summary.digest, availability.summary.digest);
let detail = runtime
.create_worker(bundled_task_request("synced", &bundle))
.unwrap();
assert_eq!(detail.config_bundle, Some(availability.reference));
}
#[test]
fn config_bundle_errors_are_typed() {
let runtime = Runtime::new_memory();
let bundle = test_bundle();
let missing = runtime
.create_worker(bundled_task_request("missing", &bundle))
.unwrap_err();
assert!(matches!(missing, RuntimeError::ConfigBundleMissing { .. }));
runtime.store_config_bundle(bundle.clone()).unwrap();
let mismatch = runtime
.check_config_bundle(&ConfigBundleRef {
id: bundle.metadata.id.clone(),
digest: "0".repeat(64),
})
.unwrap_err();
assert!(matches!(
mismatch,
RuntimeError::ConfigBundleDigestMismatch { .. }
));
let mut bad_profile = bundled_task_request("bad profile", &bundle);
bad_profile.profile = ProfileSelector::Builtin("builtin:reviewer".to_string());
let invalid_profile = runtime.create_worker(bad_profile).unwrap_err();
assert!(matches!(
invalid_profile,
RuntimeError::InvalidProfileSelector { .. }
));
let mut unsupported = test_bundle();
unsupported.declarations.push(ConfigDeclaration {
kind: ConfigDeclarationKind::Unsupported,
name: "plugin-registry".to_string(),
reference: "plugin-registry:v0".to_string(),
});
unsupported = unsupported.with_computed_digest();
let unsupported_err = runtime.store_config_bundle(unsupported).unwrap_err();
assert!(matches!(
unsupported_err,
RuntimeError::UnsupportedConfigDeclaration { .. }
));
}
#[test] #[test]
fn rejects_worker_refs_from_another_runtime() { fn rejects_worker_refs_from_another_runtime() {
let runtime_a = Runtime::new_memory(); let runtime_a = Runtime::new_memory();

View File

@ -7,11 +7,14 @@ publish = false
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
manifest = { workspace = true } manifest = { workspace = true }
futures.workspace = true
pod-store = { workspace = true } pod-store = { workspace = true }
protocol = { workspace = true }
project-record.workspace = true project-record.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
rusqlite.workspace = true rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
@ -20,6 +23,8 @@ sha2.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket.workspace = true ticket.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
tokio-tungstenite.workspace = true
worker-runtime = { workspace = true, features = ["ws-server"] }
toml.workspace = true toml.workspace = true
tracing.workspace = true tracing.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }

View File

@ -0,0 +1,625 @@
use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use worker_runtime::catalog::{CapabilityRequest, ProfileSelector};
use crate::hosts::{
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
WorkerSummary,
};
const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime";
const MAX_MESSAGE_CHARS: usize = 8_000;
const PROVIDERLESS_RESPONSE: &str =
include_str!("../../../resources/prompts/worker/web_companion_providerless.md");
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CompanionState {
Ready,
Busy,
Error,
Timeout,
Cancelled,
Accepted,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionStatusResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
pub transport: CompanionTransportSummary,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTransportSummary {
pub kind: String,
pub completion: String,
pub limitation: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageRequest {
pub content: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
pub struct CompanionCancelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionMessageResponse {
pub state: CompanionState,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker: Option<WorkerSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_item: Option<CompanionTranscriptItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assistant_item: Option<CompanionTranscriptItem>,
pub transcript: CompanionTranscriptProjection,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptProjection {
pub state: CompanionState,
pub start: usize,
pub limit: usize,
pub total_items: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_start: Option<usize>,
pub items: Vec<CompanionTranscriptItem>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanionTranscriptItem {
pub sequence: u64,
pub role: String,
pub content: String,
pub created_at: String,
pub source: String,
pub status: String,
}
#[derive(Debug, Default)]
struct CompanionTranscript {
items: Vec<CompanionTranscriptItem>,
next_sequence: u64,
}
#[derive(Debug)]
struct CompanionWorkerState {
state: CompanionState,
worker: Option<WorkerSummary>,
diagnostics: Vec<RuntimeDiagnostic>,
}
pub struct CompanionConsole {
runtime: Arc<RuntimeRegistry>,
worker: Mutex<CompanionWorkerState>,
transcript: Mutex<CompanionTranscript>,
}
impl CompanionConsole {
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self {
let initial = spawn_companion_worker(&runtime);
Self {
runtime,
worker: Mutex::new(initial),
transcript: Mutex::new(CompanionTranscript::default()),
}
}
pub fn status(&self) -> CompanionStatusResponse {
let worker = match self.worker.lock() {
Ok(worker) => worker,
Err(_) => {
return CompanionStatusResponse {
state: CompanionState::Error,
worker: None,
transport: providerless_transport(),
diagnostics: vec![diagnostic(
"companion_state_unavailable",
DiagnosticSeverity::Error,
"Companion state is unavailable",
)],
};
}
};
CompanionStatusResponse {
state: worker.state,
worker: worker.worker.clone(),
transport: providerless_transport(),
diagnostics: worker.diagnostics.clone(),
}
}
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
let transcript = match self.transcript.lock() {
Ok(transcript) => transcript,
Err(_) => {
return CompanionTranscriptProjection {
state: CompanionState::Error,
start,
limit,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
)],
};
}
};
project_transcript(&transcript, CompanionState::Ready, start, limit, Vec::new())
}
pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse {
let content = request.content.trim().to_string();
if content.is_empty() {
return self.rejected_message_response(diagnostic(
"companion_message_empty",
DiagnosticSeverity::Warning,
"Companion message content is empty",
));
}
if content.chars().count() > MAX_MESSAGE_CHARS {
return self.rejected_message_response(diagnostic(
"companion_message_too_large",
DiagnosticSeverity::Warning,
format!("Companion message exceeds the {MAX_MESSAGE_CHARS} character limit"),
));
}
let mut transcript = match self.transcript.try_lock() {
Ok(transcript) => transcript,
Err(std::sync::TryLockError::WouldBlock) => {
return self.busy_message_response();
}
Err(std::sync::TryLockError::Poisoned(_)) => {
return self.error_message_response(diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
));
}
};
let (worker, mut diagnostics) = match self.current_worker() {
Ok((Some(worker), diagnostics)) => (worker, diagnostics),
Ok((None, diagnostics)) => {
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
None,
None,
None,
0,
200,
diagnostics,
);
}
Err(diagnostic) => {
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
None,
None,
None,
0,
200,
vec![diagnostic],
);
}
};
let user_item = transcript.push("user", content.clone(), "browser_request", "accepted");
match self.runtime.send_input(
&worker.runtime_id,
&worker.worker_id,
WorkerInputRequest {
kind: WorkerInputKind::User,
content,
},
) {
Ok(result) if result.state == WorkerOperationState::Accepted => {
diagnostics.extend(result.diagnostics);
}
Ok(result) => {
diagnostics.extend(result.diagnostics);
diagnostics.push(diagnostic(
"companion_runtime_input_rejected",
DiagnosticSeverity::Error,
"Embedded Companion Worker rejected the browser message",
));
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
Some(worker),
Some(user_item),
None,
0,
200,
diagnostics,
);
}
Err(error) => {
diagnostics.push(diagnostic(
"companion_runtime_input_failed",
DiagnosticSeverity::Error,
format!("Embedded Companion Worker input failed: {error:?}"),
));
return response_from_locked_transcript(
&transcript,
CompanionState::Error,
Some(worker),
Some(user_item),
None,
0,
200,
diagnostics,
);
}
}
diagnostics.push(diagnostic(
"companion_providerless_boundary",
DiagnosticSeverity::Info,
"Real LLM completion is not connected in this MVP; response is the backend provider-less boundary text",
));
let assistant_item = transcript.push(
"assistant",
providerless_response_text(),
"backend_providerless_boundary",
"complete",
);
response_from_locked_transcript(
&transcript,
CompanionState::Accepted,
Some(worker),
Some(user_item),
Some(assistant_item),
0,
200,
diagnostics,
)
}
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
let diagnostics = vec![diagnostic(
"companion_cancel_no_active_run",
DiagnosticSeverity::Info,
"Provider-less Companion Console has no active generation to cancel",
)];
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Cancelled,
self.status().worker,
None,
None,
0,
200,
diagnostics,
),
Err(_) => CompanionMessageResponse {
state: CompanionState::Error,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
)],
},
diagnostics,
},
}
}
fn current_worker(
&self,
) -> Result<(Option<WorkerSummary>, Vec<RuntimeDiagnostic>), RuntimeDiagnostic> {
let worker = self.worker.lock().map_err(|_| {
diagnostic(
"companion_state_unavailable",
DiagnosticSeverity::Error,
"Companion state is unavailable",
)
})?;
Ok((worker.worker.clone(), worker.diagnostics.clone()))
}
fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Rejected,
self.status().worker,
None,
None,
0,
200,
vec![diagnostic],
),
Err(_) => CompanionMessageResponse {
state: CompanionState::Rejected,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic.clone()],
},
diagnostics: vec![diagnostic],
},
}
}
fn busy_message_response(&self) -> CompanionMessageResponse {
let diagnostic = diagnostic(
"companion_busy",
DiagnosticSeverity::Warning,
"Companion Console is already processing a message",
);
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Busy,
self.status().worker,
None,
None,
0,
200,
vec![diagnostic],
),
Err(_) => CompanionMessageResponse {
state: CompanionState::Busy,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Busy,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic.clone()],
},
diagnostics: vec![diagnostic],
},
}
}
fn error_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
CompanionMessageResponse {
state: CompanionState::Error,
worker: self.status().worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic.clone()],
},
diagnostics: vec![diagnostic],
}
}
}
impl CompanionTranscript {
fn push(
&mut self,
role: impl Into<String>,
content: impl Into<String>,
source: impl Into<String>,
status: impl Into<String>,
) -> CompanionTranscriptItem {
self.next_sequence = self.next_sequence.saturating_add(1);
let item = CompanionTranscriptItem {
sequence: self.next_sequence,
role: role.into(),
content: content.into(),
created_at: Utc::now().to_rfc3339(),
source: source.into(),
status: status.into(),
};
self.items.push(item.clone());
item
}
}
fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
let request = WorkerSpawnRequest {
intent: WorkerSpawnIntent::WorkspaceCompanion,
requested_worker_name: Some("workspace-companion".to_string()),
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: Some(ProfileSelector::RuntimeDefault),
config_bundle: None,
requested_capabilities: vec![CapabilityRequest::named("conversation")],
};
match runtime.spawn_worker(COMPANION_RUNTIME_ID, request) {
Ok(result) if result.state == WorkerOperationState::Accepted => CompanionWorkerState {
state: CompanionState::Ready,
worker: result.worker,
diagnostics: result.diagnostics,
},
Ok(result) => CompanionWorkerState {
state: CompanionState::Error,
worker: result.worker,
diagnostics: result.diagnostics,
},
Err(error) => CompanionWorkerState {
state: CompanionState::Error,
worker: None,
diagnostics: vec![diagnostic(
"companion_worker_spawn_failed",
DiagnosticSeverity::Error,
format!("Companion Worker spawn failed: {error:?}"),
)],
},
}
}
fn response_from_locked_transcript(
transcript: &CompanionTranscript,
state: CompanionState,
worker: Option<WorkerSummary>,
user_item: Option<CompanionTranscriptItem>,
assistant_item: Option<CompanionTranscriptItem>,
start: usize,
limit: usize,
diagnostics: Vec<RuntimeDiagnostic>,
) -> CompanionMessageResponse {
CompanionMessageResponse {
state,
worker,
user_item,
assistant_item,
transcript: project_transcript(transcript, state, start, limit, diagnostics.clone()),
diagnostics,
}
}
fn project_transcript(
transcript: &CompanionTranscript,
state: CompanionState,
start: usize,
limit: usize,
diagnostics: Vec<RuntimeDiagnostic>,
) -> CompanionTranscriptProjection {
let limit = limit.min(200);
let total_items = transcript.items.len();
let end = start.saturating_add(limit).min(total_items);
let items = if start < total_items {
transcript.items[start..end].to_vec()
} else {
Vec::new()
};
CompanionTranscriptProjection {
state,
start,
limit,
total_items,
next_start: (end < total_items).then_some(end),
items,
diagnostics,
}
}
fn providerless_response_text() -> String {
PROVIDERLESS_RESPONSE.trim().to_string()
}
fn providerless_transport() -> CompanionTransportSummary {
CompanionTransportSummary {
kind: "providerless_backend_internal".to_string(),
completion: "synchronous_request_response".to_string(),
limitation: "No provider-backed LLM generation is wired in this MVP; browser messages are recorded by a backend-internal tools-less Companion Worker and receive a resource-defined boundary response.".to_string(),
}
}
fn diagnostic(
code: impl Into<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: code.into(),
severity,
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hosts::{EmbeddedWorkerRuntime, LocalWorkerRuntime, RuntimeRegistry};
#[test]
fn companion_spawns_visible_worker_and_records_providerless_turn() {
let registry = RuntimeRegistry::for_workspace(
LocalWorkerRuntime::new("local:test", "/workspace/project", None),
EmbeddedWorkerRuntime::new_memory("local:test"),
);
let registry = Arc::new(registry);
let companion = CompanionConsole::new(registry.clone());
let status = companion.status();
assert_eq!(status.state, CompanionState::Ready);
let worker = status.worker.clone().expect("companion worker");
assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID);
assert_eq!(worker.role.as_deref(), Some("workspace_companion"));
assert!(!worker.capabilities.can_stop);
let workers = registry.list_workers(10);
assert!(
workers
.items
.iter()
.any(|item| item.worker_id == worker.worker_id)
);
let response = companion.send_message(CompanionMessageRequest {
content: "hello".to_string(),
});
assert_eq!(response.state, CompanionState::Accepted);
assert_eq!(response.transcript.items.len(), 2);
assert_eq!(response.transcript.items[0].role, "user");
assert_eq!(response.transcript.items[1].role, "assistant");
assert!(
response.transcript.items[1]
.content
.contains("provider-less")
);
let runtime_transcript = registry
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 10)
.unwrap();
assert_eq!(runtime_transcript.items.len(), 1);
assert_eq!(runtime_transcript.items[0].role, "user");
let browser_payload = serde_json::to_string(&(status, response)).unwrap();
for forbidden in [
"/workspace/project",
"metadata.json",
".jsonl",
"/run/user/",
] {
assert!(
!browser_payload.contains(forbidden),
"companion projection leaked forbidden term {forbidden}: {browser_payload}"
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,10 @@
//! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files //! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files
//! remain the canonical project records and are read through bounded bridge APIs. //! remain the canonical project records and are read through bounded bridge APIs.
pub mod companion;
pub mod hosts; pub mod hosts;
pub mod identity; pub mod identity;
pub mod observation;
pub mod records; pub mod records;
pub mod repositories; pub mod repositories;
pub mod server; pub mod server;
@ -40,10 +42,21 @@ pub enum Error {
MissingFrontmatter(String), MissingFrontmatter(String),
#[error("unknown local host `{0}`")] #[error("unknown local host `{0}`")]
UnknownHost(String), UnknownHost(String),
#[error("unknown local worker `{0}`")] #[error("unknown runtime `{0}`")]
UnknownWorker(String), UnknownRuntime(String),
#[error("unknown worker `{worker_id}` in runtime `{runtime_id}`")]
UnknownWorker {
runtime_id: String,
worker_id: String,
},
#[error("invalid runtime {kind} `{value}`")] #[error("invalid runtime {kind} `{value}`")]
InvalidRuntimeIdentifier { kind: String, value: String }, InvalidRuntimeIdentifier { kind: String, value: String },
#[error("runtime `{runtime_id}` operation failed ({code}): {message}")]
RuntimeOperationFailed {
runtime_id: String,
code: String,
message: String,
},
#[error("runtime `{runtime_id}` does not support `{capability}`")] #[error("runtime `{runtime_id}` does not support `{capability}`")]
RuntimeCapabilityUnsupported { RuntimeCapabilityUnsupported {
runtime_id: String, runtime_id: String,

View File

@ -0,0 +1,553 @@
use std::collections::{BTreeMap, VecDeque};
use std::sync::{Arc, Mutex};
use axum::http::StatusCode;
use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::{Error as TungsteniteError, Message as TungsteniteMessage};
use worker_runtime::http_server::{RuntimeWorkerEventWsEnvelope, RuntimeWorkerEventWsFrame};
/// Backend-private source for a runtime worker observation stream.
#[derive(Clone, PartialEq, Eq)]
pub struct RuntimeObservationSourceConfig {
pub runtime_id: String,
pub worker_id: String,
pub endpoint: String,
pub bearer_token: Option<String>,
}
impl std::fmt::Debug for RuntimeObservationSourceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeObservationSourceConfig")
.field("runtime_id", &self.runtime_id)
.field("worker_id", &self.worker_id)
.field("endpoint", &"<backend-private>")
.field(
"bearer_token",
&self.bearer_token.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
/// Event consumed from a Runtime-owned worker observation WebSocket.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RuntimeObservationUpstreamEvent {
pub runtime_id: String,
pub worker_id: String,
pub runtime_cursor: String,
pub payload: protocol::Event,
}
/// Backend-local frame exposed to browser/future-TUI clients.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ClientWorkerEventWsFrame {
Event {
envelope: ClientWorkerEventWsEnvelope,
},
Diagnostic {
diagnostic: ClientWorkerEventWsDiagnostic,
},
}
/// Backend-owned opaque event envelope. It intentionally omits Runtime endpoints,
/// credentials, sockets and session paths.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClientWorkerEventWsEnvelope {
pub cursor: String,
pub event_id: String,
pub runtime_id: String,
pub worker_id: String,
pub payload: protocol::Event,
}
/// Client-facing typed observation diagnostic.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientWorkerEventWsDiagnostic {
pub code: String,
pub message: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ClientWorkerEventsWsQuery {
pub cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ObservationProxyError {
RuntimeUnavailable(String),
WorkerNotFound(String),
CursorMalformed(String),
CursorUnknownOrExpired(String),
UpstreamDisconnect(String),
MalformedFrame(String),
ObservationOnly,
}
impl ObservationProxyError {
pub fn code(&self) -> &'static str {
match self {
ObservationProxyError::RuntimeUnavailable(_) => "backend.runtime_unavailable",
ObservationProxyError::WorkerNotFound(_) => "backend.worker_not_found",
ObservationProxyError::CursorMalformed(_) => "backend.cursor_malformed",
ObservationProxyError::CursorUnknownOrExpired(_) => "backend.cursor_unknown_or_expired",
ObservationProxyError::UpstreamDisconnect(_) => "backend.upstream_disconnect",
ObservationProxyError::MalformedFrame(_) => "backend.malformed_frame",
ObservationProxyError::ObservationOnly => "backend.observation_only",
}
}
pub fn message(&self) -> &str {
match self {
ObservationProxyError::RuntimeUnavailable(message)
| ObservationProxyError::WorkerNotFound(message)
| ObservationProxyError::CursorMalformed(message)
| ObservationProxyError::CursorUnknownOrExpired(message)
| ObservationProxyError::UpstreamDisconnect(message)
| ObservationProxyError::MalformedFrame(message) => message,
ObservationProxyError::ObservationOnly => {
"backend worker event WebSocket is observation-only"
}
}
}
}
impl ClientWorkerEventWsFrame {
pub fn event(envelope: ClientWorkerEventWsEnvelope) -> Self {
Self::Event { envelope }
}
pub fn diagnostic(error: ObservationProxyError) -> Self {
Self::Diagnostic {
diagnostic: ClientWorkerEventWsDiagnostic {
code: error.code().to_string(),
message: error.message().to_string(),
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct BackendObservationCursor {
pub sequence: u64,
}
impl BackendObservationCursor {
pub fn new(sequence: u64) -> Self {
Self { sequence }
}
pub fn zero() -> Self {
Self { sequence: 0 }
}
pub fn encode(self) -> String {
format!("bo_{:016x}", self.sequence)
}
pub fn decode(value: &str) -> Option<Self> {
let encoded = value.strip_prefix("bo_")?;
if encoded.len() != 16 {
return None;
}
u64::from_str_radix(encoded, 16)
.ok()
.map(|sequence| Self { sequence })
}
}
#[derive(Debug, Default)]
struct BackendObservationState {
next_sequence: u64,
history: BTreeMap<ObservationKey, VecDeque<StoredBackendEvent>>,
}
impl BackendObservationState {
fn new() -> Self {
Self {
next_sequence: 1,
history: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct ObservationKey {
runtime_id: String,
worker_id: String,
}
#[derive(Clone, Debug)]
struct StoredBackendEvent {
sequence: u64,
runtime_cursor: String,
envelope: ClientWorkerEventWsEnvelope,
}
#[derive(Clone, Debug)]
pub struct BackendObservationOpen {
pub replay: Vec<ClientWorkerEventWsEnvelope>,
pub runtime_cursor: Option<String>,
pub backend_cursor: BackendObservationCursor,
}
/// Backend-owned in-memory v0 observation proxy state.
#[derive(Clone)]
pub struct BackendObservationProxy {
sources: Arc<BTreeMap<ObservationKey, RuntimeObservationSourceConfig>>,
state: Arc<Mutex<BackendObservationState>>,
}
impl std::fmt::Debug for BackendObservationProxy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BackendObservationProxy")
.field("source_count", &self.sources.len())
.field("state", &"<omitted>")
.finish()
}
}
impl BackendObservationProxy {
pub fn new(sources: Vec<RuntimeObservationSourceConfig>) -> Self {
let sources = sources
.into_iter()
.map(|source| {
(
ObservationKey {
runtime_id: source.runtime_id.clone(),
worker_id: source.worker_id.clone(),
},
source,
)
})
.collect();
Self {
sources: Arc::new(sources),
state: Arc::new(Mutex::new(BackendObservationState::new())),
}
}
pub fn source(
&self,
runtime_id: &str,
worker_id: &str,
) -> Result<RuntimeObservationSourceConfig, ObservationProxyError> {
self.sources
.get(&ObservationKey {
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
})
.cloned()
.ok_or_else(|| {
ObservationProxyError::WorkerNotFound(format!(
"worker {worker_id} is not registered for runtime {runtime_id}"
))
})
}
pub fn open(
&self,
runtime_id: &str,
worker_id: &str,
cursor: Option<&str>,
) -> Result<BackendObservationOpen, ObservationProxyError> {
let key = ObservationKey {
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
};
let cursor = match cursor {
Some(raw) => BackendObservationCursor::decode(raw).ok_or_else(|| {
ObservationProxyError::CursorMalformed(format!(
"malformed backend observation cursor: {raw}"
))
})?,
None => BackendObservationCursor::zero(),
};
let state = self.state.lock().map_err(|_| {
ObservationProxyError::RuntimeUnavailable(
"backend observation state lock poisoned".into(),
)
})?;
let history = state.history.get(&key);
let replay: Vec<_> = history
.into_iter()
.flat_map(|events| events.iter())
.filter(|event| event.sequence > cursor.sequence)
.cloned()
.collect();
if cursor.sequence != 0 {
let found = history
.into_iter()
.flat_map(|events| events.iter())
.any(|event| event.sequence == cursor.sequence);
if !found {
return Err(ObservationProxyError::CursorUnknownOrExpired(format!(
"backend observation cursor {} is unknown or expired for runtime {runtime_id} worker {worker_id}",
cursor.encode()
)));
}
}
let runtime_cursor = replay
.last()
.map(|event| event.runtime_cursor.clone())
.or_else(|| {
history.and_then(|events| {
events
.iter()
.find(|event| event.sequence == cursor.sequence)
.map(|event| event.runtime_cursor.clone())
})
});
Ok(BackendObservationOpen {
replay: replay.into_iter().map(|event| event.envelope).collect(),
runtime_cursor,
backend_cursor: cursor,
})
}
pub fn store(
&self,
event: RuntimeObservationUpstreamEvent,
) -> Result<ClientWorkerEventWsEnvelope, ObservationProxyError> {
let mut state = self.state.lock().map_err(|_| {
ObservationProxyError::RuntimeUnavailable(
"backend observation state lock poisoned".into(),
)
})?;
let sequence = state.next_sequence;
state.next_sequence += 1;
let cursor = BackendObservationCursor::new(sequence).encode();
let envelope = ClientWorkerEventWsEnvelope {
cursor: cursor.clone(),
event_id: cursor,
runtime_id: event.runtime_id.clone(),
worker_id: event.worker_id.clone(),
payload: event.payload,
};
let key = ObservationKey {
runtime_id: event.runtime_id,
worker_id: event.worker_id,
};
let history = state.history.entry(key).or_default();
history.push_back(StoredBackendEvent {
sequence,
runtime_cursor: event.runtime_cursor,
envelope: envelope.clone(),
});
while history.len() > 1024 {
history.pop_front();
}
Ok(envelope)
}
}
fn map_runtime_connect_error(error: TungsteniteError) -> ObservationProxyError {
match error {
TungsteniteError::Http(response) if response.status() == StatusCode::NOT_FOUND => {
ObservationProxyError::WorkerNotFound(
"runtime worker observation endpoint returned 404 not found".into(),
)
}
TungsteniteError::Http(response) if response.status() == StatusCode::BAD_REQUEST => {
ObservationProxyError::CursorMalformed(
"runtime worker observation endpoint rejected the request as malformed".into(),
)
}
TungsteniteError::Http(response) => ObservationProxyError::RuntimeUnavailable(format!(
"runtime worker observation endpoint rejected WebSocket upgrade with status {}",
response.status()
)),
error => ObservationProxyError::RuntimeUnavailable(format!(
"failed to connect runtime WebSocket: {error}"
)),
}
}
fn map_runtime_diagnostic(code: String, message: String) -> ObservationProxyError {
match code.as_str() {
"runtime.worker_not_found" => ObservationProxyError::WorkerNotFound(message),
"runtime.cursor_malformed" => ObservationProxyError::CursorMalformed(message),
"runtime.cursor_unknown_or_expired" | "runtime.cursor_expired" => {
ObservationProxyError::CursorUnknownOrExpired(message)
}
"runtime.unavailable" => ObservationProxyError::RuntimeUnavailable(message),
"runtime.upstream_closed" | "runtime.websocket_error" => {
ObservationProxyError::UpstreamDisconnect(message)
}
"runtime.serialize_failed" => ObservationProxyError::MalformedFrame(message),
"runtime.observation_only" => ObservationProxyError::ObservationOnly,
_ => ObservationProxyError::RuntimeUnavailable(format!(
"runtime diagnostic {code}: {message}"
)),
}
}
pub struct RuntimeWsObservationClient {
runtime_id: String,
worker_id: String,
stream: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
}
impl RuntimeWsObservationClient {
pub async fn connect(
source: &RuntimeObservationSourceConfig,
runtime_cursor: Option<&str>,
) -> Result<Self, ObservationProxyError> {
let mut endpoint = source.endpoint.clone();
if let Some(cursor) = runtime_cursor {
let separator = if endpoint.contains('?') { '&' } else { '?' };
endpoint.push(separator);
endpoint.push_str("cursor=");
endpoint.push_str(cursor);
}
let mut request = endpoint.into_client_request().map_err(|error| {
ObservationProxyError::RuntimeUnavailable(format!(
"failed to build runtime WebSocket request: {error}"
))
})?;
if let Some(token) = &source.bearer_token {
request.headers_mut().insert(
"authorization",
format!("Bearer {token}").parse().map_err(|error| {
ObservationProxyError::RuntimeUnavailable(format!(
"failed to build runtime authorization header: {error}"
))
})?,
);
}
let (stream, _) = connect_async(request)
.await
.map_err(map_runtime_connect_error)?;
Ok(Self {
runtime_id: source.runtime_id.clone(),
worker_id: source.worker_id.clone(),
stream,
})
}
pub async fn next_event(
&mut self,
) -> Result<RuntimeObservationUpstreamEvent, ObservationProxyError> {
loop {
let Some(message) = self.stream.next().await else {
return Err(ObservationProxyError::UpstreamDisconnect(
"runtime WebSocket closed".into(),
));
};
let message = message.map_err(|error| {
ObservationProxyError::UpstreamDisconnect(format!(
"runtime WebSocket receive error: {error}"
))
})?;
let text = match message {
TungsteniteMessage::Text(text) => text,
TungsteniteMessage::Close(_) => {
return Err(ObservationProxyError::UpstreamDisconnect(
"runtime WebSocket closed".into(),
));
}
TungsteniteMessage::Ping(payload) => {
self.stream
.send(TungsteniteMessage::Pong(payload))
.await
.map_err(|error| {
ObservationProxyError::UpstreamDisconnect(format!(
"failed to reply to runtime ping: {error}"
))
})?;
continue;
}
TungsteniteMessage::Pong(_) => continue,
TungsteniteMessage::Binary(_) | TungsteniteMessage::Frame(_) => {
return Err(ObservationProxyError::MalformedFrame(
"runtime sent a non-text observation frame".into(),
));
}
};
let frame: RuntimeWorkerEventWsFrame =
serde_json::from_str(&text).map_err(|error| {
ObservationProxyError::MalformedFrame(format!(
"failed to decode runtime observation frame: {error}"
))
})?;
match frame {
RuntimeWorkerEventWsFrame::Event { envelope } => {
return Ok(self.map_envelope(envelope));
}
RuntimeWorkerEventWsFrame::Diagnostic { diagnostic } => {
return Err(map_runtime_diagnostic(diagnostic.code, diagnostic.message));
}
}
}
}
fn map_envelope(
&self,
envelope: RuntimeWorkerEventWsEnvelope,
) -> RuntimeObservationUpstreamEvent {
RuntimeObservationUpstreamEvent {
runtime_id: self.runtime_id.clone(),
worker_id: self.worker_id.clone(),
runtime_cursor: envelope.cursor,
payload: envelope.payload,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sensitive_source() -> RuntimeObservationSourceConfig {
RuntimeObservationSourceConfig {
runtime_id: "remote-runtime".to_string(),
worker_id: "worker-1".to_string(),
endpoint: "wss://remote.example.invalid/private/workers/worker-1/events/ws".to_string(),
bearer_token: Some("top-secret-bearer-token".to_string()),
}
}
#[test]
fn runtime_observation_source_debug_redacts_endpoint_and_token() {
let debug = format!("{:?}", sensitive_source());
assert!(debug.contains("remote-runtime"));
assert!(debug.contains("worker-1"));
assert!(debug.contains("<backend-private>"));
assert!(debug.contains("<redacted>"));
for forbidden in [
"remote.example.invalid",
"/private/workers/worker-1/events/ws",
"top-secret-bearer-token",
] {
assert!(
!debug.contains(forbidden),
"debug leaked {forbidden}: {debug}"
);
}
}
#[test]
fn backend_observation_proxy_debug_redacts_contained_sources() {
let proxy = BackendObservationProxy::new(vec![sensitive_source()]);
let debug = format!("{proxy:?}");
assert!(debug.contains("BackendObservationProxy"));
assert!(debug.contains("source_count"));
for forbidden in [
"remote.example.invalid",
"/private/workers/worker-1/events/ws",
"top-secret-bearer-token",
] {
assert!(
!debug.contains(forbidden),
"debug leaked {forbidden}: {debug}"
);
}
}
}

View File

@ -1,26 +1,41 @@
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path as AxumPath, Query, State}; use axum::extract::{Path as AxumPath, Query, State};
use axum::http::header::CONTENT_TYPE; use axum::http::header::CONTENT_TYPE;
use axum::http::{StatusCode, Uri}; use axum::http::{StatusCode, Uri};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use futures::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
CompanionStatusResponse, CompanionTranscriptProjection,
};
use crate::hosts::{ use crate::hosts::{
DiagnosticSeverity, HostSummary, LocalWorkerRuntime, RuntimeDiagnostic, RuntimeSummary, ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
WorkerRuntimeRegistry, WorkerSummary, HostSummary, LocalWorkerRuntime, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic,
RuntimeRegistry, RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest,
WorkerLifecycleResult, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary,
WorkerTranscriptProjection,
}; };
use crate::identity::WorkspaceIdentity; use crate::identity::WorkspaceIdentity;
use crate::observation::{
BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery,
ObservationProxyError, RuntimeObservationSourceConfig, RuntimeWsObservationClient,
};
use crate::records::{ use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
}; };
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::store::{ControlPlaneStore, WorkspaceRecord};
use crate::{Error, Result}; use crate::{Error, Result};
use worker_runtime::catalog::ConfigBundleRef;
use worker_runtime::config_bundle::ConfigBundle;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AuthConfig { pub enum AuthConfig {
@ -39,6 +54,8 @@ pub struct ServerConfig {
pub auth: AuthConfig, pub auth: AuthConfig,
pub max_records: usize, pub max_records: usize,
pub local_runtime_data_dir: Option<PathBuf>, pub local_runtime_data_dir: Option<PathBuf>,
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
} }
impl ServerConfig { impl ServerConfig {
@ -55,6 +72,8 @@ impl ServerConfig {
}, },
max_records: 200, max_records: 200,
local_runtime_data_dir: manifest::paths::data_dir(), local_runtime_data_dir: manifest::paths::data_dir(),
runtime_event_sources: Vec::new(),
remote_runtime_sources: Vec::new(),
} }
} }
} }
@ -64,7 +83,9 @@ pub struct WorkspaceApi {
config: ServerConfig, config: ServerConfig,
store: Arc<dyn ControlPlaneStore>, store: Arc<dyn ControlPlaneStore>,
records: LocalProjectRecordReader, records: LocalProjectRecordReader,
runtime: Arc<WorkerRuntimeRegistry>, runtime: Arc<RuntimeRegistry>,
companion: Arc<CompanionConsole>,
observation_proxy: BackendObservationProxy,
} }
impl WorkspaceApi { impl WorkspaceApi {
@ -78,18 +99,28 @@ impl WorkspaceApi {
updated_at: config.workspace_created_at.clone(), updated_at: config.workspace_created_at.clone(),
}) })
.await?; .await?;
let runtime = Arc::new(WorkerRuntimeRegistry::for_local_pods( let mut runtime = RuntimeRegistry::for_workspace(
LocalWorkerRuntime::new( LocalWorkerRuntime::new(
config.workspace_id.clone(), config.workspace_id.clone(),
config.workspace_root.clone(), config.workspace_root.clone(),
config.local_runtime_data_dir.clone(), config.local_runtime_data_dir.clone(),
), ),
)); EmbeddedWorkerRuntime::new_memory(config.workspace_id.clone()),
);
for remote_config in config.remote_runtime_sources.iter().cloned() {
runtime
.register(RemoteWorkerRuntime::new(remote_config).map_err(|err| err.into_error())?);
}
let runtime = Arc::new(runtime);
let companion = Arc::new(CompanionConsole::new(runtime.clone()));
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
Ok(Self { Ok(Self {
records: LocalProjectRecordReader::new(config.workspace_root.clone()), records: LocalProjectRecordReader::new(config.workspace_root.clone()),
config, config,
store, store,
runtime, runtime,
companion,
observation_proxy,
}) })
} }
@ -130,6 +161,46 @@ pub fn build_router(api: WorkspaceApi) -> Router {
.route("/api/hosts", get(list_hosts)) .route("/api/hosts", get(list_hosts))
.route("/api/runtimes", get(list_runtimes)) .route("/api/runtimes", get(list_runtimes))
.route("/api/workers", get(list_workers)) .route("/api/workers", get(list_workers))
.route("/api/companion/status", get(get_companion_status))
.route("/api/companion/transcript", get(get_companion_transcript))
.route("/api/companion/messages", post(post_companion_message))
.route("/api/companion/cancel", post(post_companion_cancel))
.route(
"/api/runtimes/{runtime_id}/workers",
post(create_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/config-bundles",
post(sync_runtime_config_bundle),
)
.route(
"/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability",
get(check_runtime_config_bundle),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}",
get(get_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/input",
post(send_runtime_worker_input),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/stop",
post(stop_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/cancel",
post(cancel_runtime_worker),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/transcript",
get(get_runtime_worker_transcript),
)
.route(
"/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws",
get(worker_observation_ws),
)
.route("/api/hosts/{host_id}/workers", get(list_host_workers)) .route("/api/hosts/{host_id}/workers", get(list_host_workers))
.fallback(get(static_or_spa_fallback)) .fallback(get(static_or_spa_fallback))
.with_state(api) .with_state(api)
@ -161,6 +232,7 @@ pub struct ExtensionPoints {
pub store: String, pub store: String,
pub event_stream: ExtensionPointState, pub event_stream: ExtensionPointState,
pub host_worker_bridge: ExtensionPointState, pub host_worker_bridge: ExtensionPointState,
pub companion_console: ExtensionPointState,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -239,6 +311,12 @@ struct TicketKanbanQuery {
limit: Option<usize>, limit: Option<usize>,
} }
#[derive(Debug, Deserialize)]
struct TranscriptQuery {
start: Option<usize>,
limit: Option<usize>,
}
async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> { async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<WorkspaceResponse>> {
let schema_version = api.store.schema_version().await?; let schema_version = api.store.schema_version().await?;
let stored = api.store.get_workspace(api.workspace_id()).await?; let stored = api.store.get_workspace(api.workspace_id()).await?;
@ -263,6 +341,10 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
status: "read_only_local".to_string(), status: "read_only_local".to_string(),
note: "Local Hosts and Workers are exposed as a read-only bridge over existing Worker metadata; no direct Worker socket, scheduling, or lifecycle control is implemented.".to_string(), note: "Local Hosts and Workers are exposed as a read-only bridge over existing Worker metadata; no direct Worker socket, scheduling, or lifecycle control is implemented.".to_string(),
}, },
companion_console: ExtensionPointState {
status: "providerless_mvp".to_string(),
note: "Backend-internal tools-less Companion Worker is available through Workspace API status/transcript/message endpoints; v0 records browser messages and returns a resource-defined provider-less response instead of direct LLM generation.".to_string(),
},
}, },
})) }))
} }
@ -425,6 +507,291 @@ async fn list_workers(
workers_response(api).map(Json) workers_response(api).map(Json)
} }
async fn get_companion_status(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<CompanionStatusResponse>> {
Ok(Json(api.companion.status()))
}
async fn get_companion_transcript(
State(api): State<WorkspaceApi>,
Query(query): Query<TranscriptQuery>,
) -> ApiResult<Json<CompanionTranscriptProjection>> {
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let start = query.start.unwrap_or(0);
Ok(Json(api.companion.transcript(start, limit)))
}
async fn post_companion_message(
State(api): State<WorkspaceApi>,
Json(request): Json<CompanionMessageRequest>,
) -> ApiResult<Json<CompanionMessageResponse>> {
Ok(Json(api.companion.send_message(request)))
}
async fn post_companion_cancel(
State(api): State<WorkspaceApi>,
Json(request): Json<CompanionCancelRequest>,
) -> ApiResult<Json<CompanionMessageResponse>> {
Ok(Json(api.companion.cancel(request)))
}
async fn get_runtime_worker(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<WorkerSummary>> {
let worker = api
.runtime
.worker(&runtime_id, &worker_id)
.map_err(|err| err.into_error())?;
Ok(Json(worker))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RuntimeConfigBundleSyncRequest {
pub bundle: ConfigBundle,
}
#[derive(Debug, Deserialize)]
struct RuntimeConfigBundleAvailabilityQuery {
digest: String,
}
async fn create_runtime_worker(
State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
Json(request): Json<WorkerSpawnRequest>,
) -> ApiResult<Json<WorkerSpawnResult>> {
let result = api
.runtime
.spawn_worker(&runtime_id, request)
.map_err(|err| err.into_error())?;
Ok(Json(result))
}
async fn sync_runtime_config_bundle(
State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
Json(request): Json<RuntimeConfigBundleSyncRequest>,
) -> ApiResult<Json<ConfigBundleSyncResult>> {
let result = api
.runtime
.sync_config_bundle(&runtime_id, request.bundle)
.map_err(|err| err.into_error())?;
Ok(Json(result))
}
async fn check_runtime_config_bundle(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, bundle_id)): AxumPath<(String, String)>,
Query(query): Query<RuntimeConfigBundleAvailabilityQuery>,
) -> ApiResult<Json<ConfigBundleCheckResult>> {
let result = api
.runtime
.check_config_bundle(
&runtime_id,
ConfigBundleRef {
id: bundle_id,
digest: query.digest,
},
)
.map_err(|err| err.into_error())?;
Ok(Json(result))
}
async fn send_runtime_worker_input(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Json(request): Json<WorkerInputRequest>,
) -> ApiResult<Json<WorkerInputResult>> {
let result = api
.runtime
.send_input(&runtime_id, &worker_id, request)
.map_err(|err| err.into_error())?;
Ok(Json(result))
}
async fn stop_runtime_worker(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Json(request): Json<WorkerLifecycleRequest>,
) -> ApiResult<Json<WorkerLifecycleResult>> {
let result = api
.runtime
.stop_worker(&runtime_id, &worker_id, request)
.map_err(|err| err.into_error())?;
Ok(Json(result))
}
async fn cancel_runtime_worker(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Json(request): Json<WorkerLifecycleRequest>,
) -> ApiResult<Json<WorkerLifecycleResult>> {
let result = api
.runtime
.cancel_worker(&runtime_id, &worker_id, request)
.map_err(|err| err.into_error())?;
Ok(Json(result))
}
async fn get_runtime_worker_transcript(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Query(query): Query<TranscriptQuery>,
) -> ApiResult<Json<WorkerTranscriptProjection>> {
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let start = query.start.unwrap_or(0);
let result = api
.runtime
.transcript(&runtime_id, &worker_id, start, limit)
.map_err(|err| err.into_error())?;
Ok(Json(result))
}
async fn worker_observation_ws(
State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Query(query): Query<ClientWorkerEventsWsQuery>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
match api.observation_proxy.source(&runtime_id, &worker_id) {
Ok(source) => ws.on_upgrade(move |socket| {
worker_observation_ws_session(api.observation_proxy, source, query, socket)
}),
Err(ObservationProxyError::WorkerNotFound(_)) => {
match api.runtime.observation_source(&runtime_id, &worker_id) {
Ok(source) => ws.on_upgrade(move |socket| {
worker_observation_ws_session(api.observation_proxy, source, query, socket)
}),
Err(error) => ApiError(error.into_error()).into_response(),
}
}
Err(error) => {
let status = StatusCode::BAD_REQUEST;
(
status,
Json(serde_json::json!({
"error": error.code(),
"message": error.message(),
})),
)
.into_response()
}
}
}
async fn worker_observation_ws_session(
proxy: BackendObservationProxy,
source: RuntimeObservationSourceConfig,
query: ClientWorkerEventsWsQuery,
mut socket: WebSocket,
) {
let open = match proxy.open(
&source.runtime_id,
&source.worker_id,
query.cursor.as_deref(),
) {
Ok(open) => open,
Err(error) => {
let _ = send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::diagnostic(error))
.await;
return;
}
};
let mut backend_cursor = open.backend_cursor;
for envelope in open.replay {
backend_cursor = crate::observation::BackendObservationCursor::decode(&envelope.cursor)
.unwrap_or(backend_cursor);
if !send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::event(envelope)).await {
return;
}
}
let mut upstream =
match RuntimeWsObservationClient::connect(&source, open.runtime_cursor.as_deref()).await {
Ok(client) => client,
Err(error) => {
let _ =
send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::diagnostic(error))
.await;
return;
}
};
loop {
tokio::select! {
inbound = socket.next() => {
match inbound {
Some(Ok(WsMessage::Close(_))) | None => return,
Some(Ok(WsMessage::Ping(payload))) => {
if socket.send(WsMessage::Pong(payload)).await.is_err() {
return;
}
}
Some(Ok(WsMessage::Pong(_))) => {}
Some(Ok(_)) => {
let _ = send_client_ws_frame(
&mut socket,
ClientWorkerEventWsFrame::diagnostic(ObservationProxyError::ObservationOnly),
).await;
return;
}
Some(Err(error)) => {
let _ = send_client_ws_frame(
&mut socket,
ClientWorkerEventWsFrame::diagnostic(
ObservationProxyError::MalformedFrame(format!(
"client WebSocket receive error: {error}"
)),
),
).await;
return;
}
}
}
upstream_event = upstream.next_event() => {
match upstream_event {
Ok(event) => match proxy.store(event) {
Ok(envelope) => {
backend_cursor = crate::observation::BackendObservationCursor::decode(&envelope.cursor)
.unwrap_or(backend_cursor);
if !send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::event(envelope)).await {
return;
}
}
Err(error) => {
let _ = send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::diagnostic(error)).await;
return;
}
},
Err(error) => {
let _ = send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::diagnostic(error)).await;
return;
}
}
}
}
}
}
async fn send_client_ws_frame(socket: &mut WebSocket, frame: ClientWorkerEventWsFrame) -> bool {
match serde_json::to_string(&frame) {
Ok(text) => socket.send(WsMessage::Text(text.into())).await.is_ok(),
Err(error) => {
let fallback =
ClientWorkerEventWsFrame::diagnostic(ObservationProxyError::MalformedFrame(
format!("failed to serialize backend observation frame: {error}"),
));
let Ok(text) = serde_json::to_string(&fallback) else {
return false;
};
socket.send(WsMessage::Text(text.into())).await.is_ok()
}
}
}
async fn list_host_workers( async fn list_host_workers(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(host_id): AxumPath<String>, AxumPath(host_id): AxumPath<String>,
@ -613,10 +980,21 @@ impl IntoResponse for ApiError {
Error::InvalidRecordId(_) Error::InvalidRecordId(_)
| Error::MissingFrontmatter(_) | Error::MissingFrontmatter(_)
| Error::UnknownHost(_) | Error::UnknownHost(_)
| Error::UnknownWorker(_) | Error::UnknownRuntime(_)
| Error::UnknownWorker { .. }
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND, | Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
Error::Ticket(_) => StatusCode::NOT_FOUND, Error::Ticket(_) => StatusCode::NOT_FOUND,
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
StatusCode::UNAUTHORIZED
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => {
StatusCode::GATEWAY_TIMEOUT
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
StatusCode::NOT_IMPLEMENTED
}
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
( (
@ -637,9 +1015,13 @@ mod tests {
use super::*; use super::*;
use axum::body::{Body, to_bytes}; use axum::body::{Body, to_bytes};
use axum::http::Request; use axum::http::Request;
use serde_json::Value; use futures::{SinkExt, StreamExt};
use serde_json::{Value, json};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use tower::ServiceExt; use tower::ServiceExt;
use crate::observation::ClientWorkerEventWsDiagnostic;
use crate::store::SqliteWorkspaceStore; use crate::store::SqliteWorkspaceStore;
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
@ -746,15 +1128,63 @@ mod tests {
let runtimes = get_json(app.clone(), "/api/runtimes").await; let runtimes = get_json(app.clone(), "/api/runtimes").await;
assert_eq!(runtimes["source"], "worker_runtime_registry"); assert_eq!(runtimes["source"], "worker_runtime_registry");
assert_eq!(runtimes["items"][0]["runtime_id"], "local-worker-runtime"); assert_eq!(runtimes["items"][0]["runtime_id"], "local-worker-runtime");
assert_eq!(
runtimes["items"][0]["source"]["kind"],
"local_compatibility"
);
assert_eq!(
runtimes["items"][0]["source"]["identity_authority"],
"runtime_registry_projection"
);
assert!(!runtimes.to_string().contains("/workspace/demo"));
assert_eq!(runtimes["items"][0]["host_ids"][0], host_id); assert_eq!(runtimes["items"][0]["host_ids"][0], host_id);
let workers = get_json(app.clone(), "/api/workers").await; let workers = get_json(app.clone(), "/api/workers").await;
assert!(workers["items"].as_array().unwrap().is_empty()); let worker_items = workers["items"].as_array().unwrap();
let companion_worker = worker_items
.iter()
.find(|worker| worker["role"] == "workspace_companion")
.expect("companion worker is visible through runtime worker API");
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime");
assert_eq!(companion_worker["capabilities"]["can_stop"], false);
assert_eq!( assert_eq!(
workers["diagnostics"][0]["code"], workers["diagnostics"][0]["code"],
"local_pod_registry_unreadable" "local_pod_registry_unreadable"
); );
let companion_status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(companion_status["state"], "ready");
assert_eq!(companion_status["worker"]["role"], "workspace_companion");
assert_eq!(
companion_status["transport"]["kind"],
"providerless_backend_internal"
);
assert!(!companion_status.to_string().contains("/workspace/demo"));
let companion_message = post_json(
app.clone(),
"/api/companion/messages",
json!({ "content": "hello companion" }),
)
.await;
assert_eq!(companion_message["state"], "accepted");
assert_eq!(companion_message["transcript"]["items"][0]["role"], "user");
assert_eq!(
companion_message["transcript"]["items"][1]["role"],
"assistant"
);
assert!(
companion_message["transcript"]["items"][1]["content"]
.as_str()
.unwrap()
.contains("provider-less")
);
assert!(!companion_message.to_string().contains("/workspace/demo"));
let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await;
assert_eq!(companion_transcript["total_items"], 2);
assert_eq!(companion_transcript["items"][1]["role"], "assistant");
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await; let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
assert!(host_workers["items"].as_array().unwrap().is_empty()); assert!(host_workers["items"].as_array().unwrap().is_empty());
@ -836,6 +1266,395 @@ mod tests {
); );
} }
#[tokio::test]
async fn embedded_runtime_api_routes_by_runtime_and_worker_ids_without_leaking_internals() {
let dir = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
config.local_runtime_data_dir = Some(dir.path().join("data"));
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let app = build_router(api);
let runtimes = get_json(app.clone(), "/api/runtimes").await;
let embedded_summary = runtimes["items"]
.as_array()
.unwrap()
.iter()
.find(|runtime| runtime["runtime_id"] == "embedded-worker-runtime")
.expect("embedded runtime summary");
assert_eq!(
embedded_summary["source"]["kind"],
"embedded_worker_runtime"
);
assert_eq!(embedded_summary["source"]["status"], "active");
assert_eq!(
embedded_summary["capabilities"]["workspace_scope"],
"backend_internal"
);
assert_eq!(embedded_summary["capabilities"]["has_workspace_fs"], false);
let spawned = post_json(
app.clone(),
"/api/runtimes/embedded-worker-runtime/workers",
json!({
"intent": {
"kind": "ticket_role",
"ticket_id": "00001KVZSGT0Q",
"role": "coder"
},
"requested_worker_name": "api-friendly-name",
"acceptance": {
"kind": "run_accepted",
"expected_segments": 0
}
}),
)
.await;
assert_eq!(spawned["state"], "accepted");
let worker_id = spawned["worker"]["worker_id"].as_str().unwrap().to_string();
assert_eq!(spawned["worker"]["runtime_id"], "embedded-worker-runtime");
assert_eq!(
spawned["worker"]["workspace"]["visibility"],
"backend_internal"
);
assert_eq!(
spawned["worker"]["implementation"]["kind"],
"embedded_worker_runtime"
);
let worker = get_json(
app.clone(),
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}"),
)
.await;
assert_eq!(worker["worker_id"], worker_id);
assert_eq!(worker["runtime_id"], "embedded-worker-runtime");
let accepted = post_json(
app.clone(),
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}/input"),
json!({
"kind": "user",
"content": "hello from browser-facing api"
}),
)
.await;
assert_eq!(accepted["state"], "accepted");
assert_eq!(accepted["runtime_id"], "embedded-worker-runtime");
assert_eq!(accepted["worker_id"], worker_id);
assert_eq!(accepted["transcript_sequence"], 1);
let transcript = get_json(
app.clone(),
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}/transcript?start=0&limit=10"),
)
.await;
assert_eq!(transcript["state"], "accepted");
assert_eq!(transcript["items"][0]["role"], "user");
assert_eq!(
transcript["items"][0]["content"],
"hello from browser-facing api"
);
let wrong_runtime = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!(
"/api/runtimes/local-worker-runtime/workers/{worker_id}/input"
))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({
"kind": "user",
"content": "wrong runtime"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(wrong_runtime.status(), StatusCode::NOT_FOUND);
let projected = format!(
"{}{}{}{}{}",
embedded_summary, spawned, worker, accepted, transcript
);
for forbidden in [
dir.path().to_string_lossy().as_ref(),
"metadata.json",
"socket",
"session",
"token",
"credential",
"provider",
] {
assert!(
!projected.contains(forbidden),
"embedded api projection leaked forbidden term: {forbidden}: {projected}"
);
}
}
#[tokio::test]
async fn proxies_worker_observation_ws_with_backend_cursors_and_diagnostics() {
let runtime = worker_runtime::Runtime::new_memory();
let worker = runtime
.create_worker(worker_runtime::catalog::CreateWorkerRequest::default())
.unwrap();
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 store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
config.local_runtime_data_dir = Some(dir.path().join("data"));
config
.runtime_event_sources
.push(RuntimeObservationSourceConfig {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
endpoint: format!(
"ws://{runtime_addr}/v1/workers/{}/events/ws",
worker.worker_ref.worker_id
),
bearer_token: None,
});
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let app_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let app_addr = app_listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(app_listener, build_router(api)).await.unwrap() });
let url = format!("ws://{app_addr}/api/runtimes/runtime-a/workers/worker-a/events/ws");
let (mut stream, _) = connect_async(&url).await.unwrap();
let snapshot = next_client_frame(&mut stream).await;
let ClientWorkerEventWsFrame::Event { envelope: snapshot } = snapshot else {
panic!("expected snapshot event");
};
assert_eq!(snapshot.runtime_id, "runtime-a");
assert_eq!(snapshot.worker_id, "worker-a");
assert!(matches!(snapshot.payload, protocol::Event::Snapshot { .. }));
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::TextDelta {
text: "live".into(),
},
)
.unwrap();
let live = next_client_frame(&mut stream).await;
let ClientWorkerEventWsFrame::Event { envelope: live } = live else {
panic!("expected live event");
};
assert_eq!(live.runtime_id, "runtime-a");
assert_eq!(live.worker_id, "worker-a");
assert!(matches!(live.payload, protocol::Event::TextDelta { .. }));
let (mut resumed, _) = connect_async(format!("{url}?cursor={}", live.cursor))
.await
.unwrap();
let _snapshot = next_client_frame(&mut resumed).await;
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::TextDone {
text: "done".into(),
},
)
.unwrap();
let resumed_event = next_client_frame(&mut resumed).await;
let ClientWorkerEventWsFrame::Event {
envelope: resumed_event,
} = resumed_event
else {
panic!("expected resumed live event");
};
assert_ne!(resumed_event.cursor, live.cursor);
assert!(matches!(
resumed_event.payload,
protocol::Event::TextDone { .. }
));
let (mut malformed, _) = connect_async(format!("{url}?cursor=bad")).await.unwrap();
let diagnostic = next_client_frame(&mut malformed).await;
let ClientWorkerEventWsFrame::Diagnostic { diagnostic } = diagnostic else {
panic!("expected malformed cursor diagnostic");
};
assert_eq!(diagnostic.code, "backend.cursor_malformed");
stream.send(Message::Text("{}".into())).await.unwrap();
let mut saw_observation_only = false;
for _ in 0..3 {
if let ClientWorkerEventWsFrame::Diagnostic { diagnostic } =
next_client_frame(&mut stream).await
{
assert_eq!(diagnostic.code, "backend.observation_only");
saw_observation_only = true;
break;
}
}
assert!(saw_observation_only, "expected observation-only diagnostic");
}
#[tokio::test]
async fn proxy_reports_unknown_backend_cursor_before_upstream_connect() {
let source = RuntimeObservationSourceConfig {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
endpoint: "ws://127.0.0.1:9/not-used".into(),
bearer_token: None,
};
let (url, _dir) = spawn_workspace_proxy(source).await;
let (mut stream, _) = connect_async(format!("{url}?cursor=bo_ffffffffffffffff"))
.await
.unwrap();
let diagnostic = next_client_diagnostic(&mut stream).await;
assert_eq!(diagnostic.code, "backend.cursor_unknown_or_expired");
}
#[tokio::test]
async fn proxy_maps_runtime_cursor_diagnostic_to_typed_backend_diagnostic() {
let (_runtime, _worker_ref, endpoint) = spawn_runtime_worker().await;
let source = RuntimeObservationSourceConfig {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
endpoint: format!("{endpoint}?cursor=wo_ffffffffffffffff"),
bearer_token: None,
};
let (url, _dir) = spawn_workspace_proxy(source).await;
let (mut stream, _) = connect_async(&url).await.unwrap();
assert!(matches!(
next_client_frame(&mut stream).await,
ClientWorkerEventWsFrame::Event { envelope } if matches!(envelope.payload, protocol::Event::Snapshot { .. })
));
let diagnostic = next_client_diagnostic(&mut stream).await;
assert_eq!(diagnostic.code, "backend.cursor_unknown_or_expired");
}
#[tokio::test]
async fn proxy_maps_runtime_worker_not_found_http_404_to_typed_backend_diagnostic() {
let (_runtime, _worker_ref, endpoint) = spawn_runtime_worker().await;
let endpoint = endpoint.replace("/events/ws", "/missing-worker/events/ws");
let source = RuntimeObservationSourceConfig {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
endpoint,
bearer_token: None,
};
let (url, _dir) = spawn_workspace_proxy(source).await;
let (mut stream, _) = connect_async(&url).await.unwrap();
let diagnostic = next_client_diagnostic(&mut stream).await;
assert_eq!(diagnostic.code, "backend.worker_not_found");
}
#[tokio::test]
async fn proxy_reports_actual_upstream_disconnect_separately() {
let endpoint = spawn_closing_runtime_ws().await;
let source = RuntimeObservationSourceConfig {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
endpoint,
bearer_token: None,
};
let (url, _dir) = spawn_workspace_proxy(source).await;
let (mut stream, _) = connect_async(&url).await.unwrap();
let diagnostic = next_client_diagnostic(&mut stream).await;
assert_eq!(diagnostic.code, "backend.upstream_disconnect");
}
async fn next_client_frame(
stream: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> ClientWorkerEventWsFrame {
let message = stream.next().await.unwrap().unwrap();
let Message::Text(text) = message else {
panic!("expected text frame");
};
serde_json::from_str(&text).unwrap()
}
async fn next_client_diagnostic(
stream: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> ClientWorkerEventWsDiagnostic {
match next_client_frame(stream).await {
ClientWorkerEventWsFrame::Diagnostic { diagnostic } => diagnostic,
ClientWorkerEventWsFrame::Event { envelope } => {
panic!("expected diagnostic, got event: {envelope:?}")
}
}
}
async fn spawn_runtime_worker() -> (
worker_runtime::Runtime,
worker_runtime::identity::WorkerRef,
String,
) {
let runtime = worker_runtime::Runtime::new_memory();
let worker = runtime
.create_worker(worker_runtime::catalog::CreateWorkerRequest::default())
.unwrap();
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 endpoint = format!(
"ws://{runtime_addr}/v1/workers/{}/events/ws",
worker.worker_ref.worker_id
);
(runtime, worker.worker_ref, endpoint)
}
async fn spawn_workspace_proxy(
source: RuntimeObservationSourceConfig,
) -> (String, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::in_memory().unwrap();
let mut config = ServerConfig::local_dev(dir.path(), test_identity());
config.local_runtime_data_dir = Some(dir.path().join("data"));
let runtime_id = source.runtime_id.clone();
let worker_id = source.worker_id.clone();
config.runtime_event_sources.push(source);
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let app_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let app_addr = app_listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(app_listener, build_router(api)).await.unwrap() });
(
format!("ws://{app_addr}/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws"),
dir,
)
}
async fn spawn_closing_runtime_ws() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = tokio_tungstenite::accept_async(stream).await.unwrap();
let _ = websocket.close(None).await;
});
format!("ws://{addr}/events/ws")
}
async fn get_json(app: Router, uri: &str) -> Value { async fn get_json(app: Router, uri: &str) -> Value {
let response = app let response = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
@ -846,6 +1665,23 @@ mod tests {
serde_json::from_slice(&bytes).unwrap() serde_json::from_slice(&bytes).unwrap()
} }
async fn post_json(app: Router, uri: &str, body: Value) -> Value {
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{uri}");
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
fn write_ticket(root: &Path, id: &str, title: &str, state: &str) { fn write_ticket(root: &Path, id: &str, title: &str, state: &str) {
let ticket_dir = root.join(".yoi/tickets").join(id); let ticket_dir = root.join(".yoi/tickets").join(id);
std::fs::create_dir_all(&ticket_dir).unwrap(); std::fs::create_dir_all(&ticket_dir).unwrap();

View File

@ -11,7 +11,7 @@ use std::fmt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, ExitCode}; use std::process::{Command, ExitCode};
use client::WorkerRuntimeCommand; use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
use memory_lint::{LintCliOptions, LintStatus}; use memory_lint::{LintCliOptions, LintStatus};
use session_store::SegmentId; use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions}; use tui::{LaunchMode, LaunchOptions};
@ -286,6 +286,9 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
let mut session = None; let mut session = None;
let mut worker_name = None; let mut worker_name = None;
let mut socket_override = None; let mut socket_override = None;
let mut backend_url = None;
let mut runtime_id = None;
let mut worker_id = None;
let mut profile = None; let mut profile = None;
let mut i = 0; let mut i = 0;
@ -329,6 +332,36 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
workspace_root = PathBuf::from(value); workspace_root = PathBuf::from(value);
i += 2; i += 2;
} }
"--backend" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--backend requires a URL".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
backend_url = Some(value.clone());
i += 2;
}
"--runtime-id" | "--runtime" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--runtime-id requires a value".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--runtime-id requires a value".to_string()));
}
runtime_id = Some(value.clone());
i += 2;
}
"--worker-id" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--worker-id requires a value".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--worker-id requires a value".to_string()));
}
worker_id = Some(value.clone());
i += 2;
}
"--profile" => { "--profile" => {
let value = args let value = args
.get(i + 1) .get(i + 1)
@ -371,6 +404,38 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
workspace_root = PathBuf::from(value); workspace_root = PathBuf::from(value);
i += 1; i += 1;
} }
arg if arg.starts_with("--backend=") => {
let value = arg.trim_start_matches("--backend=");
if value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
backend_url = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--runtime-id=") => {
let value = arg.trim_start_matches("--runtime-id=");
if value.is_empty() {
return Err(ParseError("--runtime-id requires a value".to_string()));
}
runtime_id = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--runtime=") => {
let value = arg.trim_start_matches("--runtime=");
if value.is_empty() {
return Err(ParseError("--runtime-id requires a value".to_string()));
}
runtime_id = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--worker-id=") => {
let value = arg.trim_start_matches("--worker-id=");
if value.is_empty() {
return Err(ParseError("--worker-id requires a value".to_string()));
}
worker_id = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--profile=") => { arg if arg.starts_with("--profile=") => {
let value = arg.trim_start_matches("--profile="); let value = arg.trim_start_matches("--profile=");
if value.is_empty() { if value.is_empty() {
@ -390,6 +455,26 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
} }
} }
let backend_target_present =
backend_url.is_some() || runtime_id.is_some() || worker_id.is_some();
if backend_target_present
&& (backend_url.is_none() || runtime_id.is_none() || worker_id.is_none())
{
return Err(ParseError(
"--backend, --runtime-id, and --worker-id are required together".to_string(),
));
}
if backend_target_present
&& (session.is_some()
|| worker_name.is_some()
|| socket_override.is_some()
|| profile.is_some())
{
return Err(ParseError(
"Backend Runtime API target cannot be combined with --worker, --socket, --session, or --profile".to_string(),
));
}
if profile.is_some() && (session.is_some() || socket_override.is_some()) { if profile.is_some() && (session.is_some() || socket_override.is_some()) {
return Err(ParseError( return Err(ParseError(
"--profile can only be used for fresh spawn".to_string(), "--profile can only be used for fresh spawn".to_string(),
@ -404,6 +489,19 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
)); ));
} }
if backend_target_present {
return Ok(Mode::Tui {
mode: LaunchMode::BackendRuntime {
target: BackendRuntimeTarget::new(
backend_url.expect("checked by backend_target_present"),
runtime_id.expect("checked by backend_target_present"),
worker_id.expect("checked by backend_target_present"),
),
},
workspace_root,
});
}
if let Some(profile) = profile { if let Some(profile) = profile {
return Ok(Mode::Tui { return Ok(Mode::Tui {
mode: LaunchMode::Spawn { mode: LaunchMode::Spawn {
@ -901,7 +999,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n" "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
); );
} }
@ -945,6 +1043,59 @@ mod tests {
} }
} }
#[test]
fn parse_backend_runtime_target_mode() {
match parse_args_from([
"--backend",
"http://127.0.0.1:8787",
"--runtime-id",
"runtime-a",
"--worker-id",
"worker-b",
])
.unwrap()
{
Mode::Tui {
mode: LaunchMode::BackendRuntime { target },
..
} => {
assert_eq!(target.base_url, "http://127.0.0.1:8787");
assert_eq!(target.runtime_id, "runtime-a");
assert_eq!(target.worker_id, "worker-b");
}
_ => panic!("expected BackendRuntime mode"),
}
}
#[test]
fn parse_backend_runtime_target_requires_complete_identity() {
let err = parse_args_from(["--backend", "http://127.0.0.1:8787", "--worker-id", "w"])
.unwrap_err();
assert_eq!(
err.to_string(),
"--backend, --runtime-id, and --worker-id are required together"
);
}
#[test]
fn parse_backend_runtime_target_rejects_legacy_socket_mix() {
let err = parse_args_from([
"--backend",
"http://127.0.0.1:8787",
"--runtime-id",
"r",
"--worker-id",
"w",
"--worker",
"agent",
])
.unwrap_err();
assert_eq!(
err.to_string(),
"Backend Runtime API target cannot be combined with --worker, --socket, --session, or --profile"
);
}
#[test] #[test]
fn parse_bare_word_is_unknown_command() { fn parse_bare_word_is_unknown_command() {
let err = parse_args_from(["agent"]).unwrap_err(); let err = parse_args_from(["agent"]).unwrap_err();

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-PFh+ZgmktkpeLRnIDLsxdT2QcA/j5rcJzkq7A9B6E44="; cargoHash = "sha256-fdmGo/HE80wRSLE/u20YXS63G/vvHx43uoc9BivZUxQ=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -0,0 +1,3 @@
You are connected to the Yoi Workspace Web Console MVP provider-less boundary.
I received your browser message through the backend-internal Companion Worker, but this MVP does not yet run a provider-backed LLM completion from the workspace server. The transcript/status/send path is active, tools-less, and scoped to conversation projection only. A later integration can replace this resource-defined boundary response with real Worker engine output without giving the browser runtime credentials, sockets, session paths, or filesystem authority.

View File

@ -440,3 +440,240 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
} }
.console-shell {
gap: 1rem;
}
.console-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.console-status {
display: grid;
justify-items: end;
gap: 0.25rem;
min-width: 10rem;
padding: 0.75rem 0.9rem;
border-radius: 16px;
background: #ecfeff;
border: 1px solid #a5f3fc;
color: #0f766e;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.78rem;
}
.console-status small {
text-transform: none;
letter-spacing: 0;
color: #475569;
font-weight: 600;
}
.console-status[data-state='busy'] {
background: #fff7ed;
border-color: #fed7aa;
color: #c2410c;
}
.console-status[data-state='error'],
.console-status[data-state='timeout'] {
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}
.console-status[data-state='cancelled'],
.console-status[data-state='rejected'] {
background: #f8fafc;
border-color: #cbd5e1;
color: #475569;
}
.console-transport {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.75rem;
}
.console-transport div {
display: grid;
gap: 0.15rem;
}
.console-transport dt {
margin: 0;
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.console-transport dd {
margin: 0;
color: #0f172a;
font-weight: 700;
}
.console-transport p {
grid-column: 1 / -1;
margin: 0;
color: #475569;
line-height: 1.5;
}
.console-diagnostics {
display: grid;
gap: 0.4rem;
}
.diagnostic {
margin: 0;
padding: 0.55rem 0.7rem;
border-radius: 10px;
background: #eff6ff;
color: #1d4ed8;
font-size: 0.86rem;
}
.diagnostic.warning,
.diagnostic.warn {
background: #fff7ed;
color: #c2410c;
}
.diagnostic.error {
background: #fef2f2;
color: #b91c1c;
}
.transcript-card,
.composer-card {
display: grid;
gap: 1rem;
}
.transcript-list {
display: grid;
gap: 0.85rem;
list-style: none;
margin: 0;
padding: 0;
}
.transcript-item {
display: grid;
gap: 0.45rem;
padding: 0.85rem 1rem;
border-radius: 16px;
border: 1px solid #dbeafe;
background: #f8fafc;
}
.transcript-item.user {
margin-left: min(8vw, 4rem);
background: #eff6ff;
border-color: #bfdbfe;
}
.transcript-item.assistant {
margin-right: min(8vw, 4rem);
background: #f0fdf4;
border-color: #bbf7d0;
}
.message-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
color: #64748b;
font-size: 0.78rem;
}
.message-meta strong {
color: #0f172a;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.message-meta time {
margin-left: auto;
}
.transcript-item p {
margin: 0;
white-space: pre-wrap;
line-height: 1.55;
color: #1f2937;
}
.empty-state {
margin: 0;
padding: 1rem;
border-radius: 14px;
background: #f8fafc;
color: #64748b;
}
.composer-card label {
font-weight: 800;
color: #0f172a;
}
.composer-card textarea {
width: 100%;
min-height: 8rem;
resize: vertical;
border: 1px solid #cbd5e1;
border-radius: 14px;
padding: 0.85rem 1rem;
font: inherit;
color: #0f172a;
background: #ffffff;
}
.composer-card textarea:disabled {
background: #f8fafc;
color: #64748b;
}
.composer-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.7rem;
}
.composer-actions span {
margin-right: auto;
color: #64748b;
font-size: 0.85rem;
}
.composer-actions button {
border: 0;
border-radius: 999px;
padding: 0.65rem 1rem;
background: #2563eb;
color: #ffffff;
font-weight: 800;
cursor: pointer;
}
.composer-actions button.secondary {
background: #e2e8f0;
color: #334155;
}
.composer-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}

View File

@ -0,0 +1,20 @@
<script lang="ts">
type Props = {
currentPath?: string;
};
let { currentPath = '/' }: Props = $props();
const active = $derived(currentPath === '/console');
</script>
<section class="sidebar-section" aria-labelledby="companion-console-heading">
<div class="section-heading-row">
<h2 id="companion-console-heading">Console</h2>
<span class="section-count">MVP</span>
</div>
<a class:active class="nav-item" href="/console" aria-current={active ? 'page' : undefined}>
<span class="item-title">Companion Console</span>
<span class="item-meta">status · transcript · send</span>
</a>
</section>

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CompanionNavSection from './CompanionNavSection.svelte';
import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte'; import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte';
@ -41,6 +42,7 @@
</header> </header>
<nav class="sidebar-sections" aria-label="Workspace sections"> <nav class="sidebar-sections" aria-label="Workspace sections">
<CompanionNavSection {currentPath} />
<RepositoriesNavSection {workspace} {currentPath} /> <RepositoriesNavSection {workspace} {currentPath} />
<ObjectivesNavSection {currentPath} /> <ObjectivesNavSection {currentPath} />
<WorkersNavSection /> <WorkersNavSection />

View File

@ -16,6 +16,7 @@ export type WorkspaceResponse = {
extension_points: { extension_points: {
event_stream: ExtensionPoint; event_stream: ExtensionPoint;
host_worker_bridge: ExtensionPoint; host_worker_bridge: ExtensionPoint;
companion_console: ExtensionPoint;
}; };
}; };
@ -209,3 +210,57 @@ export type ObjectiveListResponse = {
invalid_records: InvalidProjectRecord[]; invalid_records: InvalidProjectRecord[];
record_authority: string; record_authority: string;
}; };
export type CompanionState =
| 'ready'
| 'busy'
| 'error'
| 'timeout'
| 'cancelled'
| 'accepted'
| 'rejected';
export type CompanionTransportSummary = {
kind: string;
completion: string;
limitation: string;
};
export type CompanionStatusResponse = {
state: CompanionState;
worker?: Worker | null;
transport: CompanionTransportSummary;
diagnostics: Diagnostic[];
};
export type CompanionTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
content: string;
created_at: string;
source: string;
status: string;
};
export type CompanionTranscriptProjection = {
state: CompanionState;
start: number;
limit: number;
total_items: number;
next_start?: number | null;
items: CompanionTranscriptItem[];
diagnostics: Diagnostic[];
};
export type CompanionMessageRequest = {
content: string;
};
export type CompanionMessageResponse = {
state: CompanionState;
worker?: Worker | null;
user_item?: CompanionTranscriptItem | null;
assistant_item?: CompanionTranscriptItem | null;
transcript: CompanionTranscriptProjection;
diagnostics: Diagnostic[];
};

View File

@ -0,0 +1,281 @@
<script lang="ts">
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type {
CompanionMessageResponse,
CompanionState,
CompanionStatusResponse,
CompanionTranscriptItem,
CompanionTranscriptProjection,
Diagnostic,
WorkspaceResponse
} from '$lib/workspace-sidebar/types';
let workspace = $state<WorkspaceResponse | null>(null);
let workspaceError = $state<string | null>(null);
let status = $state<CompanionStatusResponse | null>(null);
let transcript = $state<CompanionTranscriptProjection | null>(null);
let draft = $state('');
let operationState = $state<CompanionState>('ready');
let error = $state<string | null>(null);
let timeoutNotice = $state<string | null>(null);
let requestId = 0;
const currentPath = '/console';
const messages = $derived(transcript?.items ?? []);
const diagnostics = $derived(mergeDiagnostics(status?.diagnostics ?? [], transcript?.diagnostics ?? []));
const sending = $derived(operationState === 'busy');
const canSend = $derived(draft.trim().length > 0 && !sending);
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function postJson<T>(path: string, body: unknown, timeoutMs = 45_000): Promise<T> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
detail = '';
}
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json() as Promise<T>;
} catch (requestError) {
if (requestError instanceof DOMException && requestError.name === 'AbortError') {
operationState = 'timeout';
timeoutNotice = 'Workspace server request timed out before a Companion response arrived.';
}
throw requestError;
} finally {
window.clearTimeout(timeout);
}
}
async function loadWorkspace() {
workspaceError = null;
try {
workspace = await getJson<WorkspaceResponse>('/api/workspace');
} catch (loadError) {
workspaceError = loadError instanceof Error ? loadError.message : String(loadError);
workspace = null;
}
}
async function loadCompanion() {
error = null;
timeoutNotice = null;
try {
const [nextStatus, nextTranscript] = await Promise.all([
getJson<CompanionStatusResponse>('/api/companion/status'),
getJson<CompanionTranscriptProjection>('/api/companion/transcript?limit=200')
]);
status = nextStatus;
transcript = nextTranscript;
operationState = nextStatus.state === 'error' ? 'error' : 'ready';
} catch (loadError) {
error = loadError instanceof Error ? loadError.message : String(loadError);
operationState = 'error';
}
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
if (!content || sending) {
return;
}
const currentRequest = ++requestId;
error = null;
timeoutNotice = null;
operationState = 'busy';
try {
const response = await postJson<CompanionMessageResponse>('/api/companion/messages', { content });
if (currentRequest !== requestId) {
return;
}
operationState = response.state;
transcript = response.transcript;
if (response.worker || status) {
status = {
state: response.state === 'accepted' ? 'ready' : response.state,
worker: response.worker ?? status?.worker ?? null,
transport: status?.transport ?? {
kind: 'providerless_backend_internal',
completion: 'synchronous_request_response',
limitation: 'Companion transport metadata was not available during this response.'
},
diagnostics: response.diagnostics
};
}
if (response.state === 'accepted') {
draft = '';
operationState = 'ready';
} else if (response.state === 'busy') {
error = 'Companion is busy with another message.';
} else if (response.state === 'rejected') {
error = diagnosticsToText(response.diagnostics) || 'Companion rejected the message.';
} else if (response.state === 'error') {
error = diagnosticsToText(response.diagnostics) || 'Companion returned an error.';
}
} catch (sendError) {
if (currentRequest !== requestId) {
return;
}
if (operationState !== 'timeout') {
operationState = 'error';
}
error = sendError instanceof Error ? sendError.message : String(sendError);
}
}
async function cancelMessage() {
++requestId;
operationState = 'cancelled';
try {
const response = await postJson<CompanionMessageResponse>('/api/companion/cancel', { reason: 'browser_cancel' }, 10_000);
transcript = response.transcript;
status = status
? { ...status, state: response.state, diagnostics: response.diagnostics }
: status;
} catch (cancelError) {
error = cancelError instanceof Error ? cancelError.message : String(cancelError);
operationState = 'error';
}
}
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
return groups.flat();
}
function diagnosticsToText(items: Diagnostic[]): string {
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
}
function itemClass(item: CompanionTranscriptItem): string {
if (item.role === 'assistant') {
return 'assistant';
}
if (item.role === 'user') {
return 'user';
}
return 'system';
}
$effect(() => {
void loadWorkspace();
void loadCompanion();
});
</script>
<svelte:head>
<title>Companion Console · Yoi Workspace</title>
<meta name="description" content="Workspace Companion Web Console MVP" />
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
<main class="shell console-shell">
<section class="console-header card">
<div>
<p class="eyebrow">Backend-internal Companion</p>
<h2>Companion Console</h2>
<p class="section-note">
Browser traffic stays behind Workspace API projections. No Worker socket, session path,
runtime credential, or local session file is exposed to the frontend.
</p>
</div>
<div class="console-status" data-state={operationState}>
<span>{operationState}</span>
{#if status?.worker}
<small>{status.worker.label}</small>
{:else}
<small>worker pending</small>
{/if}
</div>
</section>
{#if status?.transport}
<section class="card console-transport" aria-label="Companion transport">
<div>
<dt>Transport</dt>
<dd>{status.transport.kind}</dd>
</div>
<div>
<dt>Completion</dt>
<dd>{status.transport.completion}</dd>
</div>
<p>{status.transport.limitation}</p>
</section>
{/if}
{#if error || timeoutNotice || diagnostics.length > 0}
<section class="card console-diagnostics" aria-label="Companion diagnostics">
{#if timeoutNotice}
<p class="diagnostic warning">{timeoutNotice}</p>
{/if}
{#if error}
<p class="diagnostic error">{error}</p>
{/if}
{#each diagnostics as diagnostic}
<p class={`diagnostic ${diagnostic.severity}`}>{diagnostic.code}: {diagnostic.message}</p>
{/each}
</section>
{/if}
<section class="card transcript-card" aria-label="Companion transcript">
<div class="runtime-heading">
<h3>Transcript</h3>
<span>{transcript?.total_items ?? 0} items</span>
</div>
{#if messages.length === 0}
<p class="empty-state">No Companion messages yet. Send a message to exercise the backend boundary.</p>
{:else}
<ol class="transcript-list">
{#each messages as message (message.sequence)}
<li class={`transcript-item ${itemClass(message)}`}>
<div class="message-meta">
<strong>{message.role}</strong>
<span>{message.status}</span>
<time datetime={message.created_at}>{message.created_at}</time>
</div>
<p>{message.content}</p>
</li>
{/each}
</ol>
{/if}
</section>
<form class="card composer-card" onsubmit={sendMessage}>
<label for="companion-message">Message Companion</label>
<textarea
id="companion-message"
bind:value={draft}
rows="4"
maxlength="8000"
placeholder="Ask or note something for the backend Companion boundary…"
disabled={sending}
></textarea>
<div class="composer-actions">
<span>{draft.trim().length}/8000</span>
<button type="button" class="secondary" onclick={loadCompanion} disabled={sending}>Refresh</button>
<button type="button" class="secondary" onclick={cancelMessage} disabled={!sending}>Cancel</button>
<button type="submit" disabled={!canSend}>Send</button>
</div>
</form>
</main>
</div>