merge: runtime backend orchestration updates
This commit is contained in:
commit
390b914268
|
|
@ -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-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"}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Backend内蔵Companion RuntimeとWeb Console MVP'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T11:45:17Z'
|
||||
updated_at: '2026-06-25T20:34:27Z'
|
||||
updated_at: '2026-06-26T08:09:07Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T20:34:27Z'
|
||||
|
|
|
|||
|
|
@ -110,4 +110,306 @@ Marked ready by `yoi ticket state`.
|
|||
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`: success(36 passed)
|
||||
- `cargo check -p yoi`: success
|
||||
- `cd web/workspace && deno task check`: success(0 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`: success(36 tests passed)
|
||||
- `cargo check -p yoi`: success
|
||||
- `cd web/workspace && deno task check`: success(0 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`: success(36 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: 'pod crateをworker crateへ改名する'
|
||||
state: 'closed'
|
||||
created_at: '2026-06-25T13:42:37Z'
|
||||
updated_at: '2026-06-25T19:40:49Z'
|
||||
updated_at: '2026-06-26T03:09:01Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T14:13:35Z'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: 'worker-runtimeにFS永続化featureを追加する'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T14:44:02Z'
|
||||
updated_at: '2026-06-25T19:49:00Z'
|
||||
updated_at: '2026-06-26T03:09:01Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T16:39:26Z'
|
||||
|
|
|
|||
|
|
@ -316,3 +316,20 @@ Scope:
|
|||
- 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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-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"}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'worker-runtimeにREST command serverを追加する'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T14:44:02Z'
|
||||
updated_at: '2026-06-25T16:56:01Z'
|
||||
updated_at: '2026-06-26T04:20:31Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T16:39:39Z'
|
||||
|
|
|
|||
|
|
@ -59,3 +59,348 @@ Escalate if:
|
|||
- 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`: success(8 passed)
|
||||
- `cargo test -p worker-runtime --features http-server`: success(11 passed)
|
||||
- `cargo test -p worker-runtime --features fs-store,http-server`: success(13 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`: success(8 passed)
|
||||
- `cargo test -p worker-runtime --features http-server`: success(lib 11 passed, bin `src/main.rs` 3 passed)
|
||||
- `cargo test -p worker-runtime --features fs-store,http-server`: success(lib 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
|
||||
|
||||
確認結果:
|
||||
- 以前の blocker(Runtime 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`: success(8 tests passed)
|
||||
- `cargo test -p worker-runtime --features http-server`: success(lib 11 tests + bin 3 tests passed)
|
||||
- `cargo test -p worker-runtime --features fs-store,http-server`: success(lib 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`: success(8 tests passed)
|
||||
- `cargo test -p worker-runtime --features http-server`: success(lib 11 tests + bin 3 tests passed)
|
||||
- `cargo test -p worker-runtime --features fs-store,http-server`: success(lib 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Runtime/Backend WebSocket observation proxyを実装する'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T14:44:02Z'
|
||||
updated_at: '2026-06-25T20:34:20Z'
|
||||
updated_at: '2026-06-26T05:16:51Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T20:34:20Z'
|
||||
|
|
|
|||
|
|
@ -150,4 +150,407 @@ Marked ready by `yoi ticket state`.
|
|||
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。前回 blocker(REST 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`: success(14 lib tests + 3 bin tests + doctests)
|
||||
- `cargo test -p yoi-workspace-server`: success(22 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`: success(14 tests passed + bin/doc tests)
|
||||
- `cargo test -p yoi-workspace-server`: success(26 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`: success(14 lib tests + 3 bin tests + doctests)
|
||||
- `cargo test -p yoi-workspace-server`: success(26 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`: success(14 lib tests + 3 bin tests + doctests)
|
||||
- `cargo test -p yoi-workspace-server`: success(26 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Backend RuntimeRegistryの基盤をworker-runtime向けに整理する'
|
||||
state: 'inprogress'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T14:44:03Z'
|
||||
updated_at: '2026-06-25T19:37:41Z'
|
||||
updated_at: '2026-06-26T03:09:01Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T16:31:28Z'
|
||||
|
|
|
|||
|
|
@ -263,3 +263,132 @@ Review result: request_changes
|
|||
- 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`: success(21 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`: success(21 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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-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"}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'RuntimeへProfile/config bundleを同期する'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T15:49:30Z'
|
||||
updated_at: '2026-06-25T16:56:06Z'
|
||||
updated_at: '2026-06-26T07:40:38Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T16:44:39Z'
|
||||
|
|
|
|||
|
|
@ -60,3 +60,389 @@ Escalate if:
|
|||
- 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`: success(10 passed)
|
||||
- `cargo test -p yoi-workspace-server`: success(34 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`: success(13 passed)
|
||||
- `cargo test -p yoi-workspace-server`: success(35 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`: success(13 tests passed)
|
||||
- `cargo test -p yoi-workspace-server`: success(35 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`: success(13 tests passed)
|
||||
- `cargo test -p yoi-workspace-server`: success(35 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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-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"}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Backend RuntimeRegistryにembedded worker-runtimeを接続する'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T16:23:58Z'
|
||||
updated_at: '2026-06-25T16:32:35Z'
|
||||
updated_at: '2026-06-26T05:47:56Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T16:31:30Z'
|
||||
|
|
|
|||
|
|
@ -59,3 +59,257 @@ Escalate if:
|
|||
- 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-goals(remote/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`: success(29 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`: success(29 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Backend RuntimeRegistryにremote worker-runtime processを接続する'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T16:23:58Z'
|
||||
updated_at: '2026-06-25T20:34:35Z'
|
||||
updated_at: '2026-06-26T06:30:50Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T20:34:35Z'
|
||||
|
|
|
|||
|
|
@ -30,4 +30,344 @@ Marked ready by `yoi ticket state`.
|
|||
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`: success(31 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`: success(33 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`: success(33 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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'TUIをRuntime API/WebSocket接続へ移行する'
|
||||
state: 'queued'
|
||||
state: 'done'
|
||||
created_at: '2026-06-25T19:32:38Z'
|
||||
updated_at: '2026-06-25T20:34:42Z'
|
||||
updated_at: '2026-06-26T08:55:31Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-25T20:34:42Z'
|
||||
|
|
|
|||
|
|
@ -30,4 +30,304 @@ Marked ready by `yoi ticket state`.
|
|||
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`: success(382 passed)
|
||||
- `cargo test -p yoi parse_backend_runtime_target`: success(3 passed)
|
||||
- `cargo test -p client backend_runtime::tests`: success(3 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`: success(382 tests passed)
|
||||
- `cargo test -p yoi parse_backend_runtime_target`: success(3 tests passed)
|
||||
- `cargo test -p client backend_runtime::tests`: success(3 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`: success(382 tests passed)
|
||||
- `cargo test -p yoi parse_backend_runtime_target`: success(3 tests passed)
|
||||
- `cargo test -p client backend_runtime::tests`: success(3 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
65
Cargo.lock
generated
|
|
@ -203,6 +203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
|
|
@ -221,8 +222,10 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -468,13 +471,17 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||
name = "client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
@ -1065,7 +1072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2536,7 +2543,7 @@ version = "0.50.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3487,7 +3494,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3544,7 +3551,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4154,7 +4161,7 @@ dependencies = [
|
|||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4422,7 +4429,19 @@ dependencies = [
|
|||
"native-tls",
|
||||
"tokio",
|
||||
"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]]
|
||||
|
|
@ -4709,6 +4728,22 @@ dependencies = [
|
|||
"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]]
|
||||
name = "type1-encoding-parser"
|
||||
version = "0.1.1"
|
||||
|
|
@ -5463,7 +5498,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5889,11 +5924,11 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"toml",
|
||||
"tools",
|
||||
"tracing",
|
||||
"tungstenite",
|
||||
"tungstenite 0.28.0",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
"wat",
|
||||
|
|
@ -5905,9 +5940,16 @@ dependencies = [
|
|||
name = "worker-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tower",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5994,9 +6036,12 @@ dependencies = [
|
|||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"futures",
|
||||
"manifest",
|
||||
"pod-store",
|
||||
"project-record",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -6006,10 +6051,12 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tower",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"worker-runtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ sha2 = "0.11"
|
|||
tempfile = "3.27"
|
||||
thiserror = "2.0"
|
||||
tokio = "1.52"
|
||||
tokio-tungstenite = "0.29"
|
||||
tower = "0.5"
|
||||
toml = "1.1"
|
||||
tracing = "0.1"
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ license.workspace = true
|
|||
protocol = { workspace = true }
|
||||
manifest = { 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 }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
694
crates/client/src/backend_runtime.rs
Normal file
694
crates/client/src/backend_runtime.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,13 @@
|
|||
//!
|
||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||
|
||||
pub mod backend_runtime;
|
||||
pub mod runtime_command;
|
||||
pub mod spawn;
|
||||
pub mod ticket_role;
|
||||
mod worker_client;
|
||||
|
||||
pub use backend_runtime::{BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeTarget};
|
||||
pub use runtime_command::WorkerRuntimeCommand;
|
||||
|
||||
pub use spawn::{
|
||||
|
|
|
|||
|
|
@ -16,16 +16,16 @@ use crossterm::event::{
|
|||
};
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::{Command, execute};
|
||||
use protocol::{Event, Method, WorkerStatus};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||
use protocol::{Method, WorkerStatus};
|
||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use session_store::SegmentId;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
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::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||
|
|
@ -171,6 +171,54 @@ pub(crate) async fn run_worker_name(
|
|||
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(
|
||||
terminal: &mut ConsoleTerminal,
|
||||
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 mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
|
||||
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(
|
||||
|
|
@ -396,7 +450,13 @@ async fn run(
|
|||
app.connected = true;
|
||||
// The Worker sends `Event::Snapshot` automatically on connect;
|
||||
// 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) => {
|
||||
app.push_error(format!(
|
||||
|
|
@ -673,9 +733,9 @@ where
|
|||
|
||||
async fn drain_terminal_events(
|
||||
app: &mut App,
|
||||
client: &mut WorkerClient,
|
||||
client: &mut ConsoleConnection,
|
||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||
runtime_command: &WorkerRuntimeCommand,
|
||||
runtime_command: Option<&WorkerRuntimeCommand>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut handled = false;
|
||||
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
||||
|
|
@ -701,7 +761,7 @@ async fn drain_terminal_events(
|
|||
|
||||
async fn drain_worker_events(
|
||||
app: &mut App,
|
||||
client: &mut WorkerClient,
|
||||
client: &mut ConsoleConnection,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut handled = false;
|
||||
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
||||
|
|
@ -721,8 +781,8 @@ async fn drain_worker_events(
|
|||
async fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
mut client: WorkerClient,
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
mut client: ConsoleConnection,
|
||||
runtime_command: Option<WorkerRuntimeCommand>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||
|
||||
|
|
@ -734,7 +794,7 @@ async fn run_loop(
|
|||
}
|
||||
|
||||
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 {
|
||||
break;
|
||||
}
|
||||
|
|
@ -746,7 +806,8 @@ async fn run_loop(
|
|||
|
||||
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
||||
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 {
|
||||
Some(ev) => {
|
||||
|
|
@ -770,9 +831,9 @@ async fn run_loop(
|
|||
|
||||
async fn handle_terminal_event(
|
||||
app: &mut App,
|
||||
client: &mut WorkerClient,
|
||||
client: &mut ConsoleConnection,
|
||||
event: TermEvent,
|
||||
_runtime_command: &WorkerRuntimeCommand,
|
||||
_runtime_command: Option<&WorkerRuntimeCommand>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match event {
|
||||
TermEvent::Key(key) => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ use crossterm::execute;
|
|||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
||||
use session_store::SegmentId;
|
||||
|
||||
use client::WorkerRuntimeCommand;
|
||||
use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchOptions {
|
||||
|
|
@ -55,6 +55,9 @@ pub enum LaunchMode {
|
|||
worker_name: String,
|
||||
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
|
||||
/// or restore the selected stopped Worker by name. Without `--all`, the picker
|
||||
/// is scoped to the current runtime workspace.
|
||||
|
|
@ -103,6 +106,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||
worker_name,
|
||||
socket_override,
|
||||
} => console::run_worker_name(worker_name, socket_override, runtime_command).await,
|
||||
LaunchMode::BackendRuntime { target } => console::run_backend_runtime(target).await,
|
||||
LaunchMode::Resume { all } => {
|
||||
console::run_resume(runtime_command, workspace_root.clone(), all).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,28 @@ version = "0.1.0"
|
|||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "worker-runtime-rest-server"
|
||||
path = "src/main.rs"
|
||||
required-features = ["http-server"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
fs-store = ["dep:serde_json"]
|
||||
http-server = ["dep:axum", "dep:serde_json", "dep:tokio", "dep:tower"]
|
||||
ws-server = ["http-server", "axum/ws", "dep:futures", "dep:protocol", "tokio/sync"]
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
protocol = { workspace = true, optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha2.workspace = true
|
||||
serde_json = { workspace = true, optional = 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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
pub struct ConfigBundleRef {
|
||||
pub id: String,
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
/// Requested capability name plus optional human-readable reason.
|
||||
|
|
|
|||
519
crates/worker-runtime/src/config_bundle.rs
Normal file
519
crates/worker-runtime/src/config_bundle.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,34 @@ pub enum RuntimeError {
|
|||
#[error("invalid request: {0}")]
|
||||
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())]
|
||||
StoreIo {
|
||||
operation: &'static str,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::catalog::{CreateWorkerRequest, WorkerStatus};
|
||||
use crate::config_bundle::ConfigBundle;
|
||||
use crate::diagnostics::RuntimeDiagnostic;
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
|
||||
|
|
@ -364,6 +365,7 @@ pub(crate) struct PersistedRuntimeState {
|
|||
pub(crate) next_event_id: u64,
|
||||
pub(crate) next_diagnostic_id: u64,
|
||||
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
||||
pub(crate) config_bundles: BTreeMap<String, ConfigBundle>,
|
||||
pub(crate) events: Vec<RuntimeEvent>,
|
||||
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
|
@ -390,6 +392,8 @@ struct RuntimeSnapshot {
|
|||
next_worker_sequence: u64,
|
||||
next_event_id: u64,
|
||||
next_diagnostic_id: u64,
|
||||
#[serde(default)]
|
||||
config_bundles: BTreeMap<String, ConfigBundle>,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +409,7 @@ impl RuntimeSnapshot {
|
|||
next_worker_sequence: state.next_worker_sequence,
|
||||
next_event_id: state.next_event_id,
|
||||
next_diagnostic_id: state.next_diagnostic_id,
|
||||
config_bundles: state.config_bundles.clone(),
|
||||
diagnostics: state.diagnostics.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -454,6 +459,7 @@ impl RuntimeSnapshot {
|
|||
next_event_id: self.next_event_id,
|
||||
next_diagnostic_id: self.next_diagnostic_id,
|
||||
workers,
|
||||
config_bundles: self.config_bundles,
|
||||
events,
|
||||
diagnostics: self.diagnostics,
|
||||
}
|
||||
|
|
|
|||
1200
crates/worker-runtime/src/http_server.rs
Normal file
1200
crates/worker-runtime/src/http_server.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -1,16 +1,20 @@
|
|||
//! 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
|
||||
//! available only through the optional `fs-store` feature. The crate defines the
|
||||
//! in-process Runtime authority surface that higher layers can later adapt into
|
||||
//! registries or web APIs.
|
||||
//! available only through the optional `fs-store` feature, and the minimal REST
|
||||
//! process adapter is available only through the optional `http-server` feature.
|
||||
//! The crate defines the in-process Runtime authority surface that higher layers
|
||||
//! can later adapt into registries or backend APIs.
|
||||
|
||||
pub mod catalog;
|
||||
pub mod config_bundle;
|
||||
pub mod diagnostics;
|
||||
pub mod error;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub mod fs_store;
|
||||
#[cfg(feature = "http-server")]
|
||||
pub mod http_server;
|
||||
pub mod identity;
|
||||
pub mod interaction;
|
||||
pub mod management;
|
||||
|
|
|
|||
346
crates/worker-runtime/src/main.rs
Normal file
346
crates/worker-runtime/src/main.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -93,3 +93,62 @@ pub struct RuntimeEventBatch {
|
|||
pub events: Vec<RuntimeEvent>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
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::error::RuntimeError;
|
||||
|
|
@ -16,9 +21,15 @@ use crate::observation::{
|
|||
EventCursor, EventSubscription, EventSubscriptionMode, RuntimeEvent, RuntimeEventBatch,
|
||||
RuntimeEventKind, TranscriptEntry, TranscriptProjection, TranscriptQuery, TranscriptRole,
|
||||
};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(feature = "ws-server")]
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
static NEXT_RUNTIME_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
|
|
@ -122,6 +133,45 @@ impl Runtime {
|
|||
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
|
||||
/// create/send/worker lifecycle mutations.
|
||||
pub fn stop_runtime(&self) -> Result<u64, RuntimeError> {
|
||||
|
|
@ -155,6 +205,7 @@ impl Runtime {
|
|||
let mut state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
validate_create_worker_request(&request)?;
|
||||
state.validate_worker_config_boundary(&request)?;
|
||||
|
||||
let worker_id = WorkerId::generated(state.next_worker_sequence);
|
||||
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.
|
||||
pub fn diagnostics(&self) -> Result<Vec<RuntimeDiagnostic>, RuntimeError> {
|
||||
Ok(self.lock()?.diagnostics.clone())
|
||||
|
|
@ -463,8 +596,15 @@ struct RuntimeState {
|
|||
next_event_id: u64,
|
||||
next_diagnostic_id: u64,
|
||||
workers: BTreeMap<WorkerId, WorkerRecord>,
|
||||
config_bundles: BTreeMap<String, ConfigBundle>,
|
||||
events: Vec<RuntimeEvent>,
|
||||
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 {
|
||||
|
|
@ -480,8 +620,15 @@ impl RuntimeState {
|
|||
next_event_id: 1,
|
||||
next_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
config_bundles: BTreeMap::new(),
|
||||
events: 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_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
config_bundles: BTreeMap::new(),
|
||||
events: 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_diagnostic_id: persisted.next_diagnostic_id,
|
||||
workers,
|
||||
config_bundles: persisted.config_bundles,
|
||||
events: persisted.events,
|
||||
diagnostics: persisted.diagnostics,
|
||||
})
|
||||
|
|
@ -572,6 +727,7 @@ impl RuntimeState {
|
|||
.iter()
|
||||
.map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record()))
|
||||
.collect(),
|
||||
config_bundles: self.config_bundles.clone(),
|
||||
events: self.events.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> {
|
||||
if worker_ref.runtime_id != self.runtime_id {
|
||||
return Err(RuntimeError::WrongRuntime {
|
||||
|
|
@ -762,6 +977,54 @@ impl RuntimeState {
|
|||
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(
|
||||
&mut self,
|
||||
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> {
|
||||
if let crate::catalog::WorkerIntent::Task { objective } = &request.intent {
|
||||
if objective.trim().is_empty() {
|
||||
|
|
@ -889,6 +1160,10 @@ fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::catalog::{CapabilityRequest, ConfigBundleRef, ProfileSelector, WorkerIntent};
|
||||
use crate::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
|
||||
ConfigDeclarationKind, ConfigProfileDescriptor,
|
||||
};
|
||||
use crate::management::RuntimeLimits;
|
||||
|
||||
fn task_request(objective: &str) -> CreateWorkerRequest {
|
||||
|
|
@ -897,15 +1172,48 @@ mod tests {
|
|||
objective: objective.to_string(),
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
config_bundle: Some(ConfigBundleRef {
|
||||
id: "bundle-1".to_string(),
|
||||
}),
|
||||
config_bundle: None,
|
||||
requested_capabilities: vec![CapabilityRequest::named("read")],
|
||||
workspace_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]
|
||||
fn create_list_and_detail_preserve_runtime_worker_authority() {
|
||||
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.status, WorkerStatus::Running);
|
||||
assert!(detail.config_bundle.is_some());
|
||||
assert!(detail.config_bundle.is_none());
|
||||
|
||||
let list = runtime.list_workers().unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
|
|
@ -925,6 +1233,73 @@ mod tests {
|
|||
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]
|
||||
fn rejects_worker_refs_from_another_runtime() {
|
||||
let runtime_a = Runtime::new_memory();
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ publish = false
|
|||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
axum = { workspace = true, features = ["ws"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
manifest = { workspace = true }
|
||||
futures.workspace = true
|
||||
pod-store = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
project-record.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
|
|
@ -20,6 +23,8 @@ sha2.workspace = true
|
|||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
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
|
||||
tracing.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
|
|
|||
625
crates/workspace-server/src/companion.rs
Normal file
625
crates/workspace-server/src/companion.rs
Normal 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
|
|
@ -4,8 +4,10 @@
|
|||
//! 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.
|
||||
|
||||
pub mod companion;
|
||||
pub mod hosts;
|
||||
pub mod identity;
|
||||
pub mod observation;
|
||||
pub mod records;
|
||||
pub mod repositories;
|
||||
pub mod server;
|
||||
|
|
@ -40,10 +42,21 @@ pub enum Error {
|
|||
MissingFrontmatter(String),
|
||||
#[error("unknown local host `{0}`")]
|
||||
UnknownHost(String),
|
||||
#[error("unknown local worker `{0}`")]
|
||||
UnknownWorker(String),
|
||||
#[error("unknown runtime `{0}`")]
|
||||
UnknownRuntime(String),
|
||||
#[error("unknown worker `{worker_id}` in runtime `{runtime_id}`")]
|
||||
UnknownWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
#[error("invalid runtime {kind} `{value}`")]
|
||||
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}`")]
|
||||
RuntimeCapabilityUnsupported {
|
||||
runtime_id: String,
|
||||
|
|
|
|||
553
crates/workspace-server/src/observation.rs
Normal file
553
crates/workspace-server/src/observation.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,41 @@
|
|||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::companion::{
|
||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||
};
|
||||
use crate::hosts::{
|
||||
DiagnosticSeverity, HostSummary, LocalWorkerRuntime, RuntimeDiagnostic, RuntimeSummary,
|
||||
WorkerRuntimeRegistry, WorkerSummary,
|
||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime,
|
||||
HostSummary, LocalWorkerRuntime, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic,
|
||||
RuntimeRegistry, RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest,
|
||||
WorkerLifecycleResult, WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary,
|
||||
WorkerTranscriptProjection,
|
||||
};
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::observation::{
|
||||
BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery,
|
||||
ObservationProxyError, RuntimeObservationSourceConfig, RuntimeWsObservationClient,
|
||||
};
|
||||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
};
|
||||
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRecord};
|
||||
use crate::{Error, Result};
|
||||
use worker_runtime::catalog::ConfigBundleRef;
|
||||
use worker_runtime::config_bundle::ConfigBundle;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AuthConfig {
|
||||
|
|
@ -39,6 +54,8 @@ pub struct ServerConfig {
|
|||
pub auth: AuthConfig,
|
||||
pub max_records: usize,
|
||||
pub local_runtime_data_dir: Option<PathBuf>,
|
||||
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
|
||||
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
|
|
@ -55,6 +72,8 @@ impl ServerConfig {
|
|||
},
|
||||
max_records: 200,
|
||||
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,
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
records: LocalProjectRecordReader,
|
||||
runtime: Arc<WorkerRuntimeRegistry>,
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
companion: Arc<CompanionConsole>,
|
||||
observation_proxy: BackendObservationProxy,
|
||||
}
|
||||
|
||||
impl WorkspaceApi {
|
||||
|
|
@ -78,18 +99,28 @@ impl WorkspaceApi {
|
|||
updated_at: config.workspace_created_at.clone(),
|
||||
})
|
||||
.await?;
|
||||
let runtime = Arc::new(WorkerRuntimeRegistry::for_local_pods(
|
||||
let mut runtime = RuntimeRegistry::for_workspace(
|
||||
LocalWorkerRuntime::new(
|
||||
config.workspace_id.clone(),
|
||||
config.workspace_root.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 {
|
||||
records: LocalProjectRecordReader::new(config.workspace_root.clone()),
|
||||
config,
|
||||
store,
|
||||
runtime,
|
||||
companion,
|
||||
observation_proxy,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +161,46 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||
.route("/api/hosts", get(list_hosts))
|
||||
.route("/api/runtimes", get(list_runtimes))
|
||||
.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))
|
||||
.fallback(get(static_or_spa_fallback))
|
||||
.with_state(api)
|
||||
|
|
@ -161,6 +232,7 @@ pub struct ExtensionPoints {
|
|||
pub store: String,
|
||||
pub event_stream: ExtensionPointState,
|
||||
pub host_worker_bridge: ExtensionPointState,
|
||||
pub companion_console: ExtensionPointState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
|
@ -239,6 +311,12 @@ struct TicketKanbanQuery {
|
|||
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>> {
|
||||
let schema_version = api.store.schema_version().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(),
|
||||
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)
|
||||
}
|
||||
|
||||
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(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(host_id): AxumPath<String>,
|
||||
|
|
@ -613,10 +980,21 @@ impl IntoResponse for ApiError {
|
|||
Error::InvalidRecordId(_)
|
||||
| Error::MissingFrontmatter(_)
|
||||
| Error::UnknownHost(_)
|
||||
| Error::UnknownWorker(_)
|
||||
| Error::UnknownRuntime(_)
|
||||
| Error::UnknownWorker { .. }
|
||||
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
|
||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
||||
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,
|
||||
};
|
||||
(
|
||||
|
|
@ -637,9 +1015,13 @@ mod tests {
|
|||
use super::*;
|
||||
use axum::body::{Body, to_bytes};
|
||||
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 crate::observation::ClientWorkerEventWsDiagnostic;
|
||||
use crate::store::SqliteWorkspaceStore;
|
||||
|
||||
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;
|
||||
assert_eq!(runtimes["source"], "worker_runtime_registry");
|
||||
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);
|
||||
|
||||
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!(
|
||||
workers["diagnostics"][0]["code"],
|
||||
"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;
|
||||
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 {
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
|
|
@ -846,6 +1665,23 @@ mod tests {
|
|||
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) {
|
||||
let ticket_dir = root.join(".yoi/tickets").join(id);
|
||||
std::fs::create_dir_all(&ticket_dir).unwrap();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use std::fmt;
|
|||
use std::path::PathBuf;
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
use client::WorkerRuntimeCommand;
|
||||
use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||
use memory_lint::{LintCliOptions, LintStatus};
|
||||
use session_store::SegmentId;
|
||||
use tui::{LaunchMode, LaunchOptions};
|
||||
|
|
@ -286,6 +286,9 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
|||
let mut session = None;
|
||||
let mut worker_name = 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 i = 0;
|
||||
|
|
@ -329,6 +332,36 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
|||
workspace_root = PathBuf::from(value);
|
||||
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" => {
|
||||
let value = args
|
||||
.get(i + 1)
|
||||
|
|
@ -371,6 +404,38 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
|||
workspace_root = PathBuf::from(value);
|
||||
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=") => {
|
||||
let value = arg.trim_start_matches("--profile=");
|
||||
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()) {
|
||||
return Err(ParseError(
|
||||
"--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 {
|
||||
return Ok(Mode::Tui {
|
||||
mode: LaunchMode::Spawn {
|
||||
|
|
@ -901,7 +999,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
|||
|
||||
fn print_help() {
|
||||
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]
|
||||
fn parse_bare_word_is_unknown_command() {
|
||||
let err = parse_args_from(["agent"]).unwrap_err();
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-PFh+ZgmktkpeLRnIDLsxdT2QcA/j5rcJzkq7A9B6E44=";
|
||||
cargoHash = "sha256-fdmGo/HE80wRSLE/u20YXS63G/vvHx43uoc9BivZUxQ=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
3
resources/prompts/worker/web_companion_providerless.md
Normal file
3
resources/prompts/worker/web_companion_providerless.md
Normal 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.
|
||||
|
|
@ -440,3 +440,240 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import CompanionNavSection from './CompanionNavSection.svelte';
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
</header>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<CompanionNavSection {currentPath} />
|
||||
<RepositoriesNavSection {workspace} {currentPath} />
|
||||
<ObjectivesNavSection {currentPath} />
|
||||
<WorkersNavSection />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type WorkspaceResponse = {
|
|||
extension_points: {
|
||||
event_stream: ExtensionPoint;
|
||||
host_worker_bridge: ExtensionPoint;
|
||||
companion_console: ExtensionPoint;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -209,3 +210,57 @@ export type ObjectiveListResponse = {
|
|||
invalid_records: InvalidProjectRecord[];
|
||||
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[];
|
||||
};
|
||||
|
|
|
|||
281
web/workspace/src/routes/console/+page.svelte
Normal file
281
web/workspace/src/routes/console/+page.svelte
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user