merge: worker console redesign
# Conflicts: # web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte
This commit is contained in:
commit
62420b7cc4
|
|
@ -0,0 +1 @@
|
||||||
|
{"id":"orch-plan-20260626-174605-1","ticket_id":"00001KW2GCPYF","kind":"accepted_plan","note":"Dashboard Queue accepted. No relation blockers, no inprogress work, orchestration worktree clean. Previous Runtime/Backend/TUI/WebConsole foundations are on develop/orchestration HEAD.","accepted_plan":{"summary":"Workspace Web Console を Companion 専用 route/sidebar から任意 Worker attach route へ再設計する。Worker list から `runtime_id + worker_id` で開き、Backend transcript/input/observation WS と protocol event rendering を使う。旧 `/console` route/fallback と standalone Console sidebar entry は残さない。","branch":"work/00001KW2GCPYF-worker-console-redesign","worktree":"/home/hare/Projects/yoi/.worktree/00001KW2GCPYF-worker-console-redesign","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `web/workspace` と必要な Backend API support (`crates/workspace-server`)、Cargo/package files の write scope を委譲する。reviewer Worker は read-only で route/navigation authority、runtime_id+worker_id attach、protocol::Event rendering semantics、metadata/diagnostics layout、legacy `/console` removal、Browser credential/path non-leak を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T17:46:05Z"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Workspace Worker Consoleを任意Worker attach前提で再設計する'
|
title: 'Workspace Worker Consoleを任意Worker attach前提で再設計する'
|
||||||
state: 'queued'
|
state: 'done'
|
||||||
created_at: '2026-06-26T17:42:10Z'
|
created_at: '2026-06-26T17:42:10Z'
|
||||||
updated_at: '2026-06-26T17:45:40Z'
|
updated_at: '2026-06-26T18:22:51Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-06-26T17:45:40Z'
|
queued_at: '2026-06-26T17:45:40Z'
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,352 @@ Marked ready by `yoi ticket state`.
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T17:46:30Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Dashboard Queue により routing 許可済み。
|
||||||
|
- Relations はなし、OrchestrationPlan も未設定だった。blocking dependency は見当たらない。
|
||||||
|
- Current `inprogress` is 0。orchestration worktree is clean and current HEAD already contains Runtime/Backend/WebSocket/WebConsole/TUI foundations.
|
||||||
|
- Ticket body は navigation/route model、attach routing、UX redesign、protocol rendering model、input/observation、Non-goals、acceptance criteria が具体的。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body: Workspace top Worker list から任意 Worker Console、`runtime_id + worker_id` authority、Companion を通常 Worker として扱う、standalone Console sidebar entry / old `/console` fallback removal、protocol::Event rendering semantics、degrade path、validation requirements。
|
||||||
|
- Relations: none。
|
||||||
|
- Orchestration plan: accepted plan `orch-plan-20260626-174605-1` recorded。
|
||||||
|
- Workspace state: queued 1 / inprogress 0、orchestration clean、child spawned count 0。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
|
||||||
|
Intent:
|
||||||
|
- Workspace Web Console を Companion 固定 UI から、Worker list 起点の任意 Worker attach Console に再設計する。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- Console target authority は `runtime_id + worker_id`。
|
||||||
|
- Browser は Runtime endpoint / credential / socket path / session path を扱わない。
|
||||||
|
- Sidebar に standalone `Console` category / navigation entry を残さない。
|
||||||
|
- Companion は通常 Worker として list から attach する。Companion 専用 route/sidebar/Console implementation は正規導線に残さない。
|
||||||
|
- 旧 `/console` route redirect/fallback 互換は残さない。
|
||||||
|
- Backend Worker API / Backend client-facing observation WS を使う。
|
||||||
|
- TUI の見た目や keybinding は移植しない。移植対象は `protocol::Event` rendering semantics。
|
||||||
|
- 未実装 Runtime/permission/advanced control UI、multi-worker split view、raw provider trace viewer は作らない。
|
||||||
|
|
||||||
|
Requirements / acceptance criteria:
|
||||||
|
- Workspace top Worker list から任意 Worker Console を開ける。
|
||||||
|
- Console route/state は `runtime_id + worker_id` を持つ。
|
||||||
|
- Worker detail/capabilities、bounded transcript/Snapshot相当、observation WS、input API を使う。
|
||||||
|
- `protocol::Event` 由来の user / assistant / thinking / tool call/result / status / error / usage / snapshot / in-flight を表示する。
|
||||||
|
- Metadata/diagnostics は compact header/details/collapsible area に逃がし、主 transcript 幅を過剰に占有しない。
|
||||||
|
- Streaming 非対応 Worker は transcript-only/manual refresh等の degrade path を明示。
|
||||||
|
- Focused Web UI tests/component tests を追加する。
|
||||||
|
- `deno task check/build`, `cargo check -p yoi`, `git diff --check`, `nix build .#yoi --no-link` が通る。
|
||||||
|
|
||||||
|
Implementation latitude:
|
||||||
|
- Route naming、Worker list UI placement、component split、protocol event state model、degrade UI の詳細は Coder が existing `web/workspace` style に合わせて選べる。
|
||||||
|
- Backend API に小さな不足があれば `crates/workspace-server` で最小 support を追加してよい。
|
||||||
|
- Existing Companion provider-less backend can remain as a normal worker source, but no special Console UI path。
|
||||||
|
|
||||||
|
Escalate if:
|
||||||
|
- Browser に Runtime endpoint/token/socket/session path を渡す必要が出る。
|
||||||
|
- old `/console` fallback を残さないと navigation が成立しないと判明する。
|
||||||
|
- Full TUI parity / tool UI / raw provider trace viewer / multi-worker split view が必要になる。
|
||||||
|
- Backend protocol/WS foundation の大幅 redesign が必要になる。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --all`
|
||||||
|
- `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:
|
||||||
|
- Companion special-case route/sidebar implementation が残ること。
|
||||||
|
- Route target が `runtime_id + worker_id` ではなく worker_id-only になること。
|
||||||
|
- Runtime endpoint/token/socket/session path leak。
|
||||||
|
- `protocol::Event` を簡易 chat message に潰しすぎること。
|
||||||
|
- Metadata/diagnostics が主 transcript を圧迫すること。
|
||||||
|
- Old `/console` redirect/fallback compatibility が残ること。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T17:46:39Z from: queued to: inprogress reason: routing_accepted_worker_console_redesign field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Routing decision: implementation_ready。
|
||||||
|
|
||||||
|
Ticket body / relations / workspace state / accepted plan を確認し、Workspace Worker Console redesign は unblocked と判断した。Relations はなく、current `inprogress` は 0。accepted plan と IntentPacket は thread / orchestration plan に記録済み。
|
||||||
|
|
||||||
|
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T17:47:26Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation kickoff:
|
||||||
|
|
||||||
|
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW2GCPYF-worker-console-redesign`
|
||||||
|
- Branch: `work/00001KW2GCPYF-worker-console-redesign`
|
||||||
|
- Coder Worker spawned: `yoi-coder-00001KW2GCPYF-worker-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 `web/workspace/**`, `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-coder-00001KW2GCPYF-worker-console at: 2026-06-26T18:07:17Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation report:
|
||||||
|
|
||||||
|
実装完了。merge / Ticket 更新 / close / cleanup は行っていない。
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
- `c3fed591095244223e6da7c84ac7f1a2e4cf8cb7 feat: worker attach workspace console`
|
||||||
|
|
||||||
|
変更概要:
|
||||||
|
|
||||||
|
### UI / routing
|
||||||
|
|
||||||
|
- 旧 canonical `/console` route を削除。
|
||||||
|
- `web/workspace/src/routes/console/+page.svelte` 削除。
|
||||||
|
- 新 Console route を追加。
|
||||||
|
- `/runtimes/[runtimeId]/workers/[workerId]/console`
|
||||||
|
- Console target authority は `runtime_id + worker_id`。
|
||||||
|
- standalone Console / Companion sidebar entry を削除。
|
||||||
|
- `CompanionNavSection.svelte` 削除。
|
||||||
|
- sidebar は Workers section から各 Worker Console へ attach する形に変更。
|
||||||
|
- Workspace top Worker list に `Open Console` attach action を追加。
|
||||||
|
- Companion は特別 route ではなく通常 Worker row として attach される想定に変更。
|
||||||
|
|
||||||
|
### Worker Console
|
||||||
|
|
||||||
|
- Backend Worker API のみを使う Console を追加。
|
||||||
|
- Worker detail: `/api/runtimes/{runtime_id}/workers/{worker_id}`
|
||||||
|
- bounded transcript: `/api/runtimes/{runtime_id}/workers/{worker_id}/transcript?limit=200`
|
||||||
|
- input: `/api/runtimes/{runtime_id}/workers/{worker_id}/input`
|
||||||
|
- observation WS: `/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws`
|
||||||
|
- Browser 側では Runtime endpoint / credential / socket path / session path を扱わない。
|
||||||
|
- `protocol::Event` 由来の表示モデルを追加。
|
||||||
|
- user / assistant / thinking / tool / status / error / usage / snapshot / in-flight を区別表示。
|
||||||
|
- transcript-only item と observation event を source として分離。
|
||||||
|
- streaming unsupported Worker には明示的 degrade 表示を追加。
|
||||||
|
- “bounded transcript + manual refresh” の経路を提示。
|
||||||
|
- Worker detail / capabilities / diagnostics は右側 card の compact/collapsible 表示にして、transcript 幅を支配しない構成。
|
||||||
|
|
||||||
|
### tests
|
||||||
|
|
||||||
|
- Web Console projection/model tests 追加。
|
||||||
|
- protocol segment rendering
|
||||||
|
- event projection
|
||||||
|
- snapshot / in-flight state
|
||||||
|
- Worker target route href encoding
|
||||||
|
- UI contract tests 追加。
|
||||||
|
- Worker list/sidebar が Worker Console href を使うこと。
|
||||||
|
- standalone Companion nav が canonical でないこと。
|
||||||
|
- Console page が backend Worker APIs を使い `/api/companion` を使わないこと。
|
||||||
|
- `deno task test` を追加。
|
||||||
|
|
||||||
|
主な変更ファイル:
|
||||||
|
- `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte`
|
||||||
|
- `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts`
|
||||||
|
- `web/workspace/src/lib/workspace-console/model.ts`
|
||||||
|
- `web/workspace/src/lib/workspace-console/model.test.ts`
|
||||||
|
- `web/workspace/src/lib/workspace-console/worker-console.ui.test.ts`
|
||||||
|
- `web/workspace/src/lib/workspace-pages/WorkspacePage.svelte`
|
||||||
|
- `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`
|
||||||
|
- `web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte`
|
||||||
|
- `web/workspace/src/lib/workspace-sidebar/types.ts`
|
||||||
|
- `web/workspace/src/app.css`
|
||||||
|
- `web/workspace/deno.json`
|
||||||
|
- `web/workspace/tsconfig.json`
|
||||||
|
|
||||||
|
削除:
|
||||||
|
- `web/workspace/src/routes/console/+page.svelte`
|
||||||
|
- `web/workspace/src/lib/workspace-sidebar/CompanionNavSection.svelte`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --all`: success
|
||||||
|
- `cargo check -p yoi`: success
|
||||||
|
- `cd web/workspace && deno task test`: success(6 tests passed)
|
||||||
|
- `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(dirty tree warning は実装 commit 前の通常 warning。build 自体は完了)
|
||||||
|
|
||||||
|
Residual risks / review focus:
|
||||||
|
- 実 Runtime / live Worker に対するブラウザ手動操作は未実施。特に observation WS の実イベント順序と snapshot/in-flight 表示の見え方は reviewer 側で確認推奨。
|
||||||
|
- `protocol::Event` projection は現在の generated protocol union を広く扱う実装だが、UI は raw trace viewer ではなく compact transcript 表示に寄せている。イベント種類ごとの表示粒度が過不足ないか確認してほしい。
|
||||||
|
- `deno task test` の UI contract tests は component runtime harness ではなく、現行 Deno/Svelte 構成に合わせた focused contract tests。将来 component test harness が入るなら置き換え候補。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: yoi-reviewer-00001KW2GCPYF-worker-console at: 2026-06-26T18:11:54Z status: request_changes -->
|
||||||
|
|
||||||
|
## Review: request changes
|
||||||
|
|
||||||
|
Review result: request_changes
|
||||||
|
|
||||||
|
ブロッカー:
|
||||||
|
|
||||||
|
- `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte`
|
||||||
|
- lines 263-267 の `$effect` が `refreshConsole()` を呼び、
|
||||||
|
- `refreshConsole()` lines 128-130 が `reloadToken += 1` で `reloadToken` を読み書きしています。
|
||||||
|
- Svelte runes の `$effect` は effect 内で同期的に読んだ state(ネストした関数呼び出し含む)を依存として追跡するため、この effect は `reloadToken` に依存しつつ自分で更新し、再実行ループになります。
|
||||||
|
- 結果として Worker detail/transcript fetch が継続的に走り、line 269 の observation WS も `reloadToken` 依存で再接続を繰り返すリスクがあります。Console が安定して開けないため、Acceptance の「Worker list can open a Console」「observation WS/input/transcript を適切に使う」を満たせません。
|
||||||
|
|
||||||
|
確認済みの良い点:
|
||||||
|
- old `web/workspace/src/routes/console` は存在せず、`/api/companion` 参照も Console 実装には残っていません。
|
||||||
|
- 新 route は `/runtimes/[runtimeId]/workers/[workerId]/console`。
|
||||||
|
- Worker list/sidebar は `runtime_id + worker_id` を `encodeURIComponent` して Console href を生成。
|
||||||
|
- protocol event projection、snapshot/in-flight、非 streaming Worker の degrade 表示、compact/collapsible diagnostics card は実装されています。
|
||||||
|
|
||||||
|
検証は read-only 方針に従い、ソース確認・`find`/`rg`・git 状態確認のみ実施しました。ビルド/テスト再実行は artifact/cache 作成の可能性があるため未実施です。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-coder-00001KW2GCPYF-worker-console at: 2026-06-26T18:19:12Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up implementation report after review request_changes:
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
- `a1083908b681b420cd5fd911ac00da01ff8b7e5d fix: stabilize worker console refresh`
|
||||||
|
|
||||||
|
修正内容:
|
||||||
|
- Worker Console の target-change `$effect` が `refreshConsole()` を経由して `reloadToken += 1` する構造を解消。
|
||||||
|
- `reloadToken` は rune state のまま、非 reactive な `nextReloadToken` から `advanceReloadToken()` で単調更新する形に変更。
|
||||||
|
- `reloadToken` 自体を同期 read/write しないため、target-change effect の dependency cycle を避ける。
|
||||||
|
- 初期表示 / target-change refresh は `loadConsoleData(target)` を直接呼ぶように分離。
|
||||||
|
- manual refresh は引き続き `advanceReloadToken()` してから Worker detail / transcript を再取得するため、WS reconnect semantics は維持。
|
||||||
|
- observation WS effect は `worker + reloadToken + consoleTarget` に依存し、manual refresh / target-change で安定して reconnect。
|
||||||
|
- focused static/contract test を追加・調整し、以下を確認。
|
||||||
|
- `reloadToken += 1` が残っていない。
|
||||||
|
- reload token advancement が `nextReloadToken` 経由。
|
||||||
|
- target-change effect が `refreshConsole()` 経由の dependency cycle に戻らない。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo fmt --all`: success
|
||||||
|
- `cargo check -p yoi`: success
|
||||||
|
- `cd web/workspace && deno task test`: success(6 passed)
|
||||||
|
- `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(dirty tree warning は未 commit 差分によるもの。build は成功)
|
||||||
|
|
||||||
|
Residual risks / review focus:
|
||||||
|
- 実ブラウザ + live Worker での操作確認は未実施。
|
||||||
|
- 今回の fix は Svelte runes の dependency loop 解消に絞っている。WS の実接続タイミングや stale async response の UX は既存設計の範囲。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: yoi-reviewer-00001KW2GCPYF-worker-console at: 2026-06-26T18:21:17Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Review result: approve
|
||||||
|
|
||||||
|
確認結果:
|
||||||
|
- 対象 worktree は branch `work/00001KW2GCPYF-worker-console-redesign`、HEAD は fix commit `a1083908b681b420cd5fd911ac00da01ff8b7e5d`。
|
||||||
|
- 前回 blocker は解消済み。
|
||||||
|
- `reloadToken += 1` は残っていない。
|
||||||
|
- target-change `$effect` は `refreshConsole()` を呼ばず、`consoleTarget` を読んで `advanceReloadToken()` + `loadConsoleData(target)` のみ実行している。
|
||||||
|
- `advanceReloadToken()` は非 reactive な `nextReloadToken` を増やして `reloadToken` に代入する形で、同じ effect 内で `reloadToken` を read/write する依存ループにはなっていない。
|
||||||
|
- observation WS effect は `worker + reloadToken + consoleTarget` に依存し、manual refresh / target change で reconnect される構造として妥当。
|
||||||
|
- regression なし。
|
||||||
|
- old `/console` route は存在しない。
|
||||||
|
- standalone `CompanionNavSection` / `/api/companion` 利用は Console 実装に戻っていない。
|
||||||
|
- Worker list/sidebar は `workerConsoleHref(worker)` 経由で `runtime_id + worker_id` route に attach している。
|
||||||
|
- Backend Worker detail/transcript/input/observation WS API の使用も維持されている。
|
||||||
|
|
||||||
|
検証:
|
||||||
|
- read-only のソース確認、git 状態確認、`find`/`rg` による route/API/sidebar 残存確認。ビルド・テスト再実行はファイル生成の可能性があるため未実施。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T18:21:56Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Integration / validation report:
|
||||||
|
|
||||||
|
Integration:
|
||||||
|
- Implementation branch `work/00001KW2GCPYF-worker-console-redesign` を orchestration branch に merge 済み。
|
||||||
|
- Merge commit: `864efe32 merge: 00001KW2GCPYF worker console redesign`
|
||||||
|
|
||||||
|
Included implementation commits:
|
||||||
|
- `c3fed591095244223e6da7c84ac7f1a2e4cf8cb7 feat: worker attach workspace console`
|
||||||
|
- `a1083908b681b420cd5fd911ac00da01ff8b7e5d fix: stabilize worker console refresh`
|
||||||
|
|
||||||
|
Validation in Orchestrator worktree:
|
||||||
|
- `cargo fmt --all --check`: success
|
||||||
|
- `cargo check -p yoi`: success
|
||||||
|
- `cd web/workspace && deno task test`: success(6 tests passed)
|
||||||
|
- `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 済み。前回 blocker だった Svelte runes `$effect` / `reloadToken` dependency loop は解消済み。
|
||||||
|
|
||||||
|
Outcome:
|
||||||
|
- Acceptance criteria を満たしたため `done` へ進める。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T18:22:10Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Workspace Worker Console redesign の実装、review、merge、Orchestrator validation が完了した。
|
||||||
|
|
||||||
|
Done evidence:
|
||||||
|
- Merge commit: `864efe32 merge: 00001KW2GCPYF worker console redesign`
|
||||||
|
- Reviewer approve 済み。
|
||||||
|
- Orchestrator validation:
|
||||||
|
- `cargo fmt --all --check`: success
|
||||||
|
- `cargo check -p yoi`: success
|
||||||
|
- `cd web/workspace && deno task test`: success(6 tests passed)
|
||||||
|
- `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
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Workspace Web Console を Companion-specific route/sidebar から Worker list driven arbitrary Worker attach route (`runtime_id + worker_id`) へ再設計。
|
||||||
|
- Old `/console` canonical route and standalone Companion Console sidebar entry were removed.
|
||||||
|
- Advanced controls / multi-worker split / raw provider trace viewer / full auth-redaction policy は Non-goals として未実装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T18:22:51Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Cleanup report:
|
||||||
|
|
||||||
|
- Child Workers stopped one-by-one and scope reclaimed:
|
||||||
|
- `yoi-coder-00001KW2GCPYF-worker-console`
|
||||||
|
- `yoi-reviewer-00001KW2GCPYF-worker-console`
|
||||||
|
- Child implementation worktree removed:
|
||||||
|
- `/home/hare/Projects/yoi/.worktree/00001KW2GCPYF-worker-console-redesign`
|
||||||
|
- Child implementation branch removed:
|
||||||
|
- `work/00001KW2GCPYF-worker-console-redesign`
|
||||||
|
|
||||||
|
Operational note:
|
||||||
|
- StopPod was executed sequentially, not in parallel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@
|
||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
|
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
|
"$lib/": "./src/lib/",
|
||||||
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
|
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
|
||||||
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
|
|
|
||||||
|
|
@ -816,3 +816,214 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-link,
|
||||||
|
.secondary-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover,
|
||||||
|
.inline-link:hover {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-console-shell {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-status-pill {
|
||||||
|
min-width: 14rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ecfeff;
|
||||||
|
border: 1px solid #a5f3fc;
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-status-pill.warn {
|
||||||
|
background: #fff7ed;
|
||||||
|
border-color: #fed7aa;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(18rem, 26rem);
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-transcript {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
max-height: min(68dvh, 50rem);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-transcript li {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #cbd5e1;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-transcript li.user {
|
||||||
|
border-left-color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-transcript li.assistant {
|
||||||
|
border-left-color: #10b981;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-transcript li.error-line {
|
||||||
|
border-left-color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-heading small {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-transcript pre {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-detail,
|
||||||
|
.metadata-details {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-detail summary,
|
||||||
|
.metadata-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-side-card {
|
||||||
|
align-content: start;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-side-card dl,
|
||||||
|
.console-side-card ul {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-side-card dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-side-card dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.degrade-note {
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-composer {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-composer label {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-composer textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 7rem;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-composer textarea:disabled {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.console-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-status-pill {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
181
web/workspace/src/lib/workspace-console/model.test.ts
Normal file
181
web/workspace/src/lib/workspace-console/model.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import type { Event } from "$lib/generated/protocol";
|
||||||
|
import { projectConsole, segmentsToText, workerConsoleHref } from "./model.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
||||||
|
assert(
|
||||||
|
workerConsoleHref({
|
||||||
|
runtime_id: "local runtime",
|
||||||
|
worker_id: "worker/one",
|
||||||
|
}) ===
|
||||||
|
"/runtimes/local%20runtime/workers/worker%2Fone/console",
|
||||||
|
"href should contain encoded runtime_id and worker_id segments",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("segmentsToText preserves protocol segment semantics", () => {
|
||||||
|
const text = segmentsToText([
|
||||||
|
{ kind: "text", content: "hello" },
|
||||||
|
{ kind: "file_ref", path: "/tmp/example.md" },
|
||||||
|
{ kind: "knowledge_ref", slug: "design-note" },
|
||||||
|
{ kind: "workflow_invoke", slug: "ticket-review" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert(text.includes("hello"), "text segment should render content");
|
||||||
|
assert(
|
||||||
|
text.includes("@file /tmp/example.md"),
|
||||||
|
"file ref should render as a file reference",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
text.includes("@knowledge design-note"),
|
||||||
|
"knowledge ref should render as a knowledge reference",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
text.includes("/ticket-review"),
|
||||||
|
"workflow invocation should render as slash command",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole keeps transcript and protocol-derived event rows distinct", () => {
|
||||||
|
const projection = projectConsole(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sequence: 1,
|
||||||
|
role: "user",
|
||||||
|
content: "transcript input",
|
||||||
|
event_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
cursor: "11",
|
||||||
|
event: {
|
||||||
|
event: "text_delta",
|
||||||
|
data: { text: "stream" },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "12",
|
||||||
|
event: {
|
||||||
|
event: "thinking_done",
|
||||||
|
data: { text: "reasoning" },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "13",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "tool-1",
|
||||||
|
summary: "read file",
|
||||||
|
output: "content",
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "14",
|
||||||
|
event: {
|
||||||
|
event: "usage",
|
||||||
|
data: { input_tokens: 12, output_tokens: 5 },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "15",
|
||||||
|
event: {
|
||||||
|
event: "error",
|
||||||
|
data: { code: "invalid_request", message: "bad frame" },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) =>
|
||||||
|
line.source === "transcript" && line.kind === "user"
|
||||||
|
),
|
||||||
|
"transcript user row expected",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) =>
|
||||||
|
line.source === "event" && line.kind === "assistant"
|
||||||
|
),
|
||||||
|
"assistant event row expected",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) => line.kind === "thinking"),
|
||||||
|
"thinking event row expected",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) => line.kind === "tool"),
|
||||||
|
"tool event row expected",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) => line.kind === "usage"),
|
||||||
|
"usage event row expected",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) => line.kind === "error" && line.error),
|
||||||
|
"error event row expected",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.usage === "input 12 · output 5 · cache unknown",
|
||||||
|
"usage summary should be retained",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole displays snapshot and in-flight state", () => {
|
||||||
|
const projection = projectConsole([], [
|
||||||
|
{
|
||||||
|
cursor: "20",
|
||||||
|
event: {
|
||||||
|
event: "snapshot",
|
||||||
|
data: {
|
||||||
|
entries: [{ role: "user" }],
|
||||||
|
greeting: {
|
||||||
|
worker_name: "Worker",
|
||||||
|
cwd: "/repo",
|
||||||
|
provider: "provider",
|
||||||
|
model: "model",
|
||||||
|
scope_summary: "bounded",
|
||||||
|
tools: ["Read"],
|
||||||
|
context_window: 100,
|
||||||
|
context_tokens: 20,
|
||||||
|
},
|
||||||
|
status: "running",
|
||||||
|
in_flight: {
|
||||||
|
blocks: [
|
||||||
|
{ kind: "text", text: "unfinished answer", finished: false },
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "Read",
|
||||||
|
args: "{}",
|
||||||
|
state: "streaming_args",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert(projection.status === "running", "snapshot should update status");
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) => line.kind === "snapshot"),
|
||||||
|
"snapshot row expected",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
projection.lines.filter((line) => line.kind === "in_flight").length === 2,
|
||||||
|
"in-flight rows expected",
|
||||||
|
);
|
||||||
|
});
|
||||||
598
web/workspace/src/lib/workspace-console/model.ts
Normal file
598
web/workspace/src/lib/workspace-console/model.ts
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
import type {
|
||||||
|
Event as ProtocolEvent,
|
||||||
|
InFlightBlock,
|
||||||
|
Segment,
|
||||||
|
} from "$lib/generated/protocol";
|
||||||
|
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
|
||||||
|
|
||||||
|
export type ConsoleLineKind =
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "thinking"
|
||||||
|
| "tool"
|
||||||
|
| "status"
|
||||||
|
| "error"
|
||||||
|
| "usage"
|
||||||
|
| "snapshot"
|
||||||
|
| "in_flight"
|
||||||
|
| "system";
|
||||||
|
|
||||||
|
export type ConsoleLine = {
|
||||||
|
id: string;
|
||||||
|
kind: ConsoleLineKind;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
detail?: string;
|
||||||
|
cursor?: string | null;
|
||||||
|
source: "transcript" | "event";
|
||||||
|
streaming?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsoleProjection = {
|
||||||
|
lines: ConsoleLine[];
|
||||||
|
status: string | null;
|
||||||
|
usage: string | null;
|
||||||
|
lastCursor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerTarget = {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function workerConsoleHref(target: WorkerTarget): string {
|
||||||
|
return `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
|
||||||
|
encodeURIComponent(
|
||||||
|
target.worker_id,
|
||||||
|
)
|
||||||
|
}/console`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workerConsolePath(runtimeId: string, workerId: string): string {
|
||||||
|
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
|
||||||
|
return items.map((item) => ({
|
||||||
|
id: `transcript-${item.event_id}-${item.sequence}`,
|
||||||
|
kind: transcriptRoleKind(item.role),
|
||||||
|
title: `${item.role} · transcript #${item.sequence}`,
|
||||||
|
body: item.content,
|
||||||
|
detail: `event ${item.event_id}`,
|
||||||
|
source: "transcript",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectConsole(
|
||||||
|
transcript: WorkerTranscriptItem[],
|
||||||
|
events: Array<{ cursor: string; event: ProtocolEvent }> = [],
|
||||||
|
): ConsoleProjection {
|
||||||
|
return events.reduce(applyProtocolEvent, {
|
||||||
|
lines: transcriptLines(transcript),
|
||||||
|
status: null,
|
||||||
|
usage: null,
|
||||||
|
lastCursor: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProtocolEvent(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
envelope: { cursor: string; event: ProtocolEvent },
|
||||||
|
): ConsoleProjection {
|
||||||
|
const next: ConsoleProjection = {
|
||||||
|
lines: [...projection.lines],
|
||||||
|
status: projection.status,
|
||||||
|
usage: projection.usage,
|
||||||
|
lastCursor: envelope.cursor,
|
||||||
|
};
|
||||||
|
const event = envelope.event;
|
||||||
|
|
||||||
|
switch (event.event) {
|
||||||
|
case "user_message":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"user",
|
||||||
|
"user message",
|
||||||
|
segmentsToText(event.data.segments),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "system_item":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"system",
|
||||||
|
"system item",
|
||||||
|
jsonPreview(event.data.item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "text_delta":
|
||||||
|
appendStreaming(
|
||||||
|
next,
|
||||||
|
envelope.cursor,
|
||||||
|
"assistant",
|
||||||
|
"assistant streaming",
|
||||||
|
event.data.text,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "text_done":
|
||||||
|
finalizeStreaming(
|
||||||
|
next,
|
||||||
|
"assistant",
|
||||||
|
envelope.cursor,
|
||||||
|
"assistant",
|
||||||
|
event.data.text,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "thinking_start":
|
||||||
|
next.lines.push(
|
||||||
|
line(envelope.cursor, "thinking", "thinking", "", undefined, true),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "thinking_delta":
|
||||||
|
appendStreaming(
|
||||||
|
next,
|
||||||
|
envelope.cursor,
|
||||||
|
"thinking",
|
||||||
|
"thinking",
|
||||||
|
event.data.text,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "thinking_done":
|
||||||
|
finalizeStreaming(
|
||||||
|
next,
|
||||||
|
"thinking",
|
||||||
|
envelope.cursor,
|
||||||
|
"thinking",
|
||||||
|
event.data.text,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "tool_call_start":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"tool",
|
||||||
|
`tool call · ${event.data.name}`,
|
||||||
|
`id: ${event.data.id}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "tool_call_args_delta":
|
||||||
|
appendToolArgs(next, envelope.cursor, event.data.id, event.data.json);
|
||||||
|
break;
|
||||||
|
case "tool_call_done":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"tool",
|
||||||
|
`tool call done · ${event.data.name}`,
|
||||||
|
event.data.arguments,
|
||||||
|
`id: ${event.data.id}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "tool_result":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"tool",
|
||||||
|
event.data.is_error ? "tool result error" : "tool result",
|
||||||
|
event.data.output ?? event.data.summary,
|
||||||
|
`id: ${event.data.id} · ${event.data.summary}`,
|
||||||
|
false,
|
||||||
|
event.data.is_error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "usage":
|
||||||
|
next.usage = usageText(event.data);
|
||||||
|
next.lines.push(line(envelope.cursor, "usage", "usage", next.usage));
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"error",
|
||||||
|
`error · ${event.data.code}`,
|
||||||
|
event.data.message,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "snapshot":
|
||||||
|
next.status = event.data.status;
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"snapshot",
|
||||||
|
`snapshot · ${event.data.status}`,
|
||||||
|
`${event.data.entries.length} entries · ${event.data.greeting.provider} / ${event.data.greeting.model}`,
|
||||||
|
`${event.data.greeting.worker_name} · context ${event.data.greeting.context_tokens}/${event.data.greeting.context_window}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const block of event.data.in_flight?.blocks ?? []) {
|
||||||
|
next.lines.push(inFlightLine(envelope.cursor, block));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
next.status = event.data.status;
|
||||||
|
next.lines.push(
|
||||||
|
line(envelope.cursor, "status", "status", event.data.status),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "invoke_start":
|
||||||
|
next.lines.push(
|
||||||
|
line(envelope.cursor, "status", "invoke start", event.data.kind),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "turn_start":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"turn start",
|
||||||
|
`turn ${event.data.turn}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "turn_end":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"turn end",
|
||||||
|
`turn ${event.data.turn} · ${event.data.result}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "llm_call_start":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"llm call start",
|
||||||
|
`call ${event.data.llm_call}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "llm_call_end":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"llm call end",
|
||||||
|
`call ${event.data.llm_call}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "llm_retry":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"llm retry",
|
||||||
|
`${event.data.error} · attempt ${event.data.failed_attempt}/${event.data.max_attempts}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "llm_continuation":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"llm continuation",
|
||||||
|
`${event.data.reason} · attempt ${event.data.attempt}/${event.data.max_attempts}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "run_end":
|
||||||
|
next.lines.push(
|
||||||
|
line(envelope.cursor, "status", "run end", event.data.result),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "alert":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
`alert · ${event.data.level}`,
|
||||||
|
event.data.message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "memory_worker":
|
||||||
|
next.lines.push(
|
||||||
|
line(envelope.cursor, "status", "memory worker", event.data.message),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "segment_rotated":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"segment rotated",
|
||||||
|
jsonPreview(event.data.entry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "completions":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"completions",
|
||||||
|
`${event.data.kind} · ${event.data.entries.length} entries`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "rewind_targets":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"rewind targets",
|
||||||
|
`${event.data.targets.length} targets · head ${event.data.head_entries}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "rewind_applied":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"rewind applied",
|
||||||
|
`${event.data.summary.discarded_entries} discarded · ${event.data.summary.truncated_to_entries} retained`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "workers_listed":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"workers listed",
|
||||||
|
jsonPreview(event.data.workers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "worker_restored":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"worker restored",
|
||||||
|
jsonPreview(event.data.result),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "peer_registered":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"peer registered",
|
||||||
|
jsonPreview(event.data.result),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "compact_start":
|
||||||
|
next.lines.push(
|
||||||
|
line(envelope.cursor, "status", "compact start", "compaction started"),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "compact_done":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"status",
|
||||||
|
"compact done",
|
||||||
|
event.data.new_segment_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "compact_failed":
|
||||||
|
next.lines.push(
|
||||||
|
line(
|
||||||
|
envelope.cursor,
|
||||||
|
"error",
|
||||||
|
"compact failed",
|
||||||
|
event.data.error,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "shutdown":
|
||||||
|
next.status = "shutdown";
|
||||||
|
next.lines.push(
|
||||||
|
line(envelope.cursor, "status", "shutdown", "worker shut down"),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentsToText(segments: Segment[]): string {
|
||||||
|
return segments
|
||||||
|
.map((segment) => {
|
||||||
|
switch (segment.kind) {
|
||||||
|
case "text":
|
||||||
|
return segment.content;
|
||||||
|
case "paste":
|
||||||
|
return segment.content ||
|
||||||
|
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
|
||||||
|
case "file_ref":
|
||||||
|
return `@file ${segment.path}`;
|
||||||
|
case "knowledge_ref":
|
||||||
|
return `@knowledge ${segment.slug}`;
|
||||||
|
case "workflow_invoke":
|
||||||
|
return `/${segment.slug}`;
|
||||||
|
case "unknown":
|
||||||
|
return "[unknown segment]";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcriptRoleKind(role: string): ConsoleLineKind {
|
||||||
|
if (role === "user" || role === "assistant" || role === "system") {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(
|
||||||
|
cursor: string,
|
||||||
|
kind: ConsoleLineKind,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
detail?: string,
|
||||||
|
streaming = false,
|
||||||
|
error = false,
|
||||||
|
): ConsoleLine {
|
||||||
|
return {
|
||||||
|
id: `event-${cursor}-${kind}-${slugify(title)}-${body.length}`,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
detail,
|
||||||
|
cursor,
|
||||||
|
source: "event",
|
||||||
|
streaming,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendStreaming(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
cursor: string,
|
||||||
|
kind: "assistant" | "thinking",
|
||||||
|
title: string,
|
||||||
|
delta: string,
|
||||||
|
): void {
|
||||||
|
const existing = [...projection.lines].reverse().find((item) =>
|
||||||
|
item.kind === kind && item.streaming
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.body += delta;
|
||||||
|
existing.cursor = cursor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projection.lines.push(line(cursor, kind, title, delta, undefined, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeStreaming(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
kind: "assistant" | "thinking",
|
||||||
|
cursor: string,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
): void {
|
||||||
|
const existing = [...projection.lines].reverse().find((item) =>
|
||||||
|
item.kind === kind && item.streaming
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.body = body || existing.body;
|
||||||
|
existing.streaming = false;
|
||||||
|
existing.title = title;
|
||||||
|
existing.cursor = cursor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projection.lines.push(line(cursor, kind, title, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToolArgs(
|
||||||
|
projection: ConsoleProjection,
|
||||||
|
cursor: string,
|
||||||
|
id: string,
|
||||||
|
delta: string,
|
||||||
|
): void {
|
||||||
|
const existing = [...projection.lines]
|
||||||
|
.reverse()
|
||||||
|
.find((item) =>
|
||||||
|
item.kind === "tool" && item.streaming && item.body.includes(`id: ${id}`)
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.body += delta;
|
||||||
|
existing.cursor = cursor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projection.lines.push(
|
||||||
|
line(
|
||||||
|
cursor,
|
||||||
|
"tool",
|
||||||
|
"tool call args",
|
||||||
|
`id: ${id}\n${delta}`,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageText(
|
||||||
|
data: {
|
||||||
|
input_tokens: number | null;
|
||||||
|
output_tokens: number | null;
|
||||||
|
cache_read_input_tokens?: number | null;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
return `input ${data.input_tokens ?? "unknown"} · output ${
|
||||||
|
data.output_tokens ?? "unknown"
|
||||||
|
} · cache ${data.cache_read_input_tokens ?? "unknown"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine {
|
||||||
|
switch (block.kind) {
|
||||||
|
case "text":
|
||||||
|
return line(
|
||||||
|
cursor,
|
||||||
|
"in_flight",
|
||||||
|
"in-flight assistant text",
|
||||||
|
block.text,
|
||||||
|
undefined,
|
||||||
|
!block.finished,
|
||||||
|
);
|
||||||
|
case "thinking":
|
||||||
|
return line(
|
||||||
|
cursor,
|
||||||
|
"in_flight",
|
||||||
|
"in-flight thinking",
|
||||||
|
block.text,
|
||||||
|
undefined,
|
||||||
|
!block.finished,
|
||||||
|
);
|
||||||
|
case "tool_call":
|
||||||
|
return line(
|
||||||
|
cursor,
|
||||||
|
"in_flight",
|
||||||
|
`in-flight tool · ${block.name}`,
|
||||||
|
block.args,
|
||||||
|
`${block.id} · ${block.state ?? "pending"}`,
|
||||||
|
block.state !== "done",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(
|
||||||
|
/^-|-$/g,
|
||||||
|
"",
|
||||||
|
) || "event";
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonPreview(value: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2) ?? "null";
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
readTextFile(path: string | URL): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
|
||||||
|
const workspacePage = await Deno.readTextFile(
|
||||||
|
new URL("../workspace-pages/WorkspacePage.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const workersNav = await Deno.readTextFile(
|
||||||
|
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const sidebar = await Deno.readTextFile(
|
||||||
|
new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
workspacePage.includes("workerConsoleHref(worker)") &&
|
||||||
|
workspacePage.includes("Open Console"),
|
||||||
|
"top Worker list should expose an attach action per Worker",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
workersNav.includes("workerConsoleHref(worker)") &&
|
||||||
|
workersNav.includes("aria-current"),
|
||||||
|
"Workers sidebar rows should link to the Worker target Console route",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!sidebar.includes("CompanionNavSection") &&
|
||||||
|
sidebar.includes("WorkersNavSection"),
|
||||||
|
"standalone Companion/Console navigation should not remain canonical",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
|
||||||
|
const consolePage = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const routeLoad = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
|
||||||
|
"route load should expose both target ids",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
consolePage.includes(
|
||||||
|
"/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}",
|
||||||
|
),
|
||||||
|
"Worker detail should use the backend Worker detail API",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
consolePage.includes("/transcript?limit=200") &&
|
||||||
|
consolePage.includes("/events/ws") && consolePage.includes("/input"),
|
||||||
|
"Console should use bounded transcript, observation WS, and input APIs",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!consolePage.includes("/api/companion"),
|
||||||
|
"Console page must not use Companion-specific APIs",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
consolePage.includes("streaming observation is not available") ||
|
||||||
|
consolePage.includes("Streaming observation is not available"),
|
||||||
|
"Console should show an explicit non-streaming degradation path",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
consolePage.includes("function advanceReloadToken()") &&
|
||||||
|
consolePage.includes("nextReloadToken += 1") &&
|
||||||
|
!consolePage.includes("reloadToken += 1"),
|
||||||
|
"reload token advancement should not synchronously read and write the rune state",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
consolePage.includes(
|
||||||
|
"advanceReloadToken();\n void loadConsoleData(target);",
|
||||||
|
) &&
|
||||||
|
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
|
||||||
|
"target-change effect should load data without depending on manual refresh state reads",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
||||||
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||||
import type {
|
import type {
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
|
|
@ -477,6 +478,7 @@
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Workspace</th>
|
<th>Workspace</th>
|
||||||
<th>Implementation</th>
|
<th>Implementation</th>
|
||||||
|
<th>Attach</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -492,6 +494,7 @@
|
||||||
<td>{worker.state} · {worker.status}</td>
|
<td>{worker.state} · {worker.status}</td>
|
||||||
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
|
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
|
||||||
<td>{worker.implementation.kind}</td>
|
<td>{worker.implementation.kind}</td>
|
||||||
|
<td><a class="inline-link" href={workerConsoleHref(worker)}>Open Console</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<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,8 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
import type { ListResponse, Worker } from './types';
|
import type { ListResponse, Worker } from './types';
|
||||||
|
|
||||||
const MAX_VISIBLE_WORKERS = 6;
|
const MAX_VISIBLE_WORKERS = 6;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { currentPath = '/' }: Props = $props();
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let workers = $state<Worker[]>([]);
|
let workers = $state<Worker[]>([]);
|
||||||
|
|
@ -63,15 +70,18 @@
|
||||||
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
|
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="nav-list" aria-label="Workers">
|
<ul class="nav-list" aria-label="Workers">
|
||||||
{#each workers as worker (worker.worker_id)}
|
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||||
<li class="nav-item worker-nav-item">
|
{@const href = workerConsoleHref(worker)}
|
||||||
|
<li>
|
||||||
|
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
||||||
<span class="worker-title-row">
|
<span class="worker-title-row">
|
||||||
<span class="item-title">{worker.label}</span>
|
<span class="item-title">{worker.label}</span>
|
||||||
<span class="worker-task-title">-</span>
|
<span class="worker-task-title">-</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="item-meta">
|
<span class="item-meta">
|
||||||
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status}
|
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
|
||||||
</span>
|
</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CompanionNavSection from './CompanionNavSection.svelte';
|
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
|
|
@ -42,9 +41,8 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<CompanionNavSection {currentPath} />
|
|
||||||
<RepositoriesNavSection {workspace} {currentPath} />
|
<RepositoriesNavSection {workspace} {currentPath} />
|
||||||
<ObjectivesNavSection {currentPath} />
|
<ObjectivesNavSection {currentPath} />
|
||||||
<WorkersNavSection />
|
<WorkersNavSection {currentPath} />
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
export type {
|
import type {
|
||||||
Event as PodProtocolEvent,
|
Event as PodProtocolEvent,
|
||||||
Method as PodProtocolMethod,
|
Method as PodProtocolMethod,
|
||||||
Segment as PodProtocolSegment,
|
Segment as PodProtocolSegment
|
||||||
} from '$lib/generated/protocol';
|
} from '$lib/generated/protocol';
|
||||||
|
|
||||||
|
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
|
||||||
|
|
||||||
export type ExtensionPoint = {
|
export type ExtensionPoint = {
|
||||||
status: string;
|
status: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
|
@ -93,6 +95,53 @@ export type Worker = {
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
|
||||||
|
|
||||||
|
export type WorkerInputResult = {
|
||||||
|
state: WorkerOperationState;
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
transcript_sequence?: number | null;
|
||||||
|
event_id?: number | null;
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerTranscriptItem = {
|
||||||
|
sequence: number;
|
||||||
|
role: 'user' | 'assistant' | 'system' | string;
|
||||||
|
content: string;
|
||||||
|
event_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerTranscriptProjection = {
|
||||||
|
state: WorkerOperationState;
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
start: number;
|
||||||
|
limit: number;
|
||||||
|
total_items: number;
|
||||||
|
next_start?: number | null;
|
||||||
|
items: WorkerTranscriptItem[];
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientWorkerEventWsEnvelope = {
|
||||||
|
cursor: string;
|
||||||
|
event_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
payload: PodProtocolEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientWorkerEventWsDiagnostic = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientWorkerEventWsFrame =
|
||||||
|
| { kind: 'event'; envelope: ClientWorkerEventWsEnvelope }
|
||||||
|
| { kind: 'diagnostic'; diagnostic: ClientWorkerEventWsDiagnostic };
|
||||||
|
|
||||||
export type ListResponse<T> = {
|
export type ListResponse<T> = {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -0,0 +1,439 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||||
|
import {
|
||||||
|
projectConsole,
|
||||||
|
workerConsolePath,
|
||||||
|
type ConsoleLine
|
||||||
|
} from '$lib/workspace-console/model';
|
||||||
|
import type {
|
||||||
|
ClientWorkerEventWsFrame,
|
||||||
|
Diagnostic,
|
||||||
|
Worker,
|
||||||
|
WorkerInputResult,
|
||||||
|
WorkerTranscriptProjection,
|
||||||
|
WorkspaceResponse
|
||||||
|
} from '$lib/workspace-sidebar/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: {
|
||||||
|
runtimeId: string;
|
||||||
|
workerId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const runtimeId = $derived(data.runtimeId);
|
||||||
|
const workerId = $derived(data.workerId);
|
||||||
|
const currentPath = $derived(workerConsolePath(runtimeId, workerId));
|
||||||
|
|
||||||
|
let workspace = $state<WorkspaceResponse | null>(null);
|
||||||
|
let workspaceError = $state<string | null>(null);
|
||||||
|
let worker = $state<Worker | null>(null);
|
||||||
|
let workerError = $state<string | null>(null);
|
||||||
|
let transcript = $state<WorkerTranscriptProjection | null>(null);
|
||||||
|
let transcriptError = $state<string | null>(null);
|
||||||
|
let draft = $state('');
|
||||||
|
let sending = $state(false);
|
||||||
|
let sendError = $state<string | null>(null);
|
||||||
|
let streamState = $state<'connecting' | 'open' | 'unsupported' | 'closed' | 'error'>('connecting');
|
||||||
|
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||||
|
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
|
||||||
|
let nextReloadToken = 0;
|
||||||
|
let reloadToken = $state(0);
|
||||||
|
|
||||||
|
type ConsoleTarget = {
|
||||||
|
runtimeId: string;
|
||||||
|
workerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const consoleTarget = $derived({ runtimeId, workerId });
|
||||||
|
|
||||||
|
const projection = $derived(
|
||||||
|
projectConsole(
|
||||||
|
transcript?.items ?? [],
|
||||||
|
observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const lines = $derived(projection.lines);
|
||||||
|
const diagnostics = $derived(
|
||||||
|
mergeDiagnostics(worker?.diagnostics ?? [], transcript?.diagnostics ?? [], streamDiagnostics)
|
||||||
|
);
|
||||||
|
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
|
||||||
|
const transcriptOnly = $derived(
|
||||||
|
worker && !worker.capabilities.can_stream_events
|
||||||
|
? 'Streaming observation is not available for this Worker. Console is using bounded transcript plus manual refresh.'
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = 30_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>;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspace() {
|
||||||
|
workspaceError = null;
|
||||||
|
try {
|
||||||
|
workspace = await getJson<WorkspaceResponse>('/api/workspace');
|
||||||
|
} catch (error) {
|
||||||
|
workspaceError = error instanceof Error ? error.message : String(error);
|
||||||
|
workspace = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorker(target: ConsoleTarget) {
|
||||||
|
workerError = null;
|
||||||
|
try {
|
||||||
|
worker = await getJson<Worker>(
|
||||||
|
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
workerError = error instanceof Error ? error.message : String(error);
|
||||||
|
worker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTranscript(target: ConsoleTarget) {
|
||||||
|
transcriptError = null;
|
||||||
|
try {
|
||||||
|
transcript = await getJson<WorkerTranscriptProjection>(
|
||||||
|
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
transcriptError = error instanceof Error ? error.message : String(error);
|
||||||
|
transcript = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConsoleData(target: ConsoleTarget) {
|
||||||
|
await Promise.all([loadWorker(target), loadTranscript(target)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceReloadToken(): number {
|
||||||
|
nextReloadToken += 1;
|
||||||
|
reloadToken = nextReloadToken;
|
||||||
|
return nextReloadToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshConsole() {
|
||||||
|
advanceReloadToken();
|
||||||
|
await loadConsoleData(consoleTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const content = draft.trim();
|
||||||
|
if (!content || sending || !worker?.capabilities.can_accept_input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sending = true;
|
||||||
|
sendError = null;
|
||||||
|
try {
|
||||||
|
const result = await postJson<WorkerInputResult>(
|
||||||
|
`/api/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`,
|
||||||
|
{ kind: 'user', content }
|
||||||
|
);
|
||||||
|
if (result.state === 'accepted') {
|
||||||
|
draft = '';
|
||||||
|
} else {
|
||||||
|
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
||||||
|
}
|
||||||
|
await loadTranscript(consoleTarget);
|
||||||
|
} catch (error) {
|
||||||
|
sendError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
|
||||||
|
if (!targetWorker) {
|
||||||
|
streamState = 'closed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!targetWorker.capabilities.can_stream_events) {
|
||||||
|
streamState = 'unsupported';
|
||||||
|
streamDiagnostics = [
|
||||||
|
{
|
||||||
|
code: 'worker_streaming_unsupported',
|
||||||
|
severity: 'info',
|
||||||
|
message: 'This Worker does not expose backend-proxied observation streaming; transcript refresh remains available.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamState = 'connecting';
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`${protocol}//${window.location.host}/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
|
||||||
|
target.workerId
|
||||||
|
)}/events/ws`
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (token === reloadToken) {
|
||||||
|
streamState = 'open';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onmessage = (message) => {
|
||||||
|
if (token !== reloadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
||||||
|
if (frame.kind === 'event') {
|
||||||
|
observedEvents = [
|
||||||
|
...observedEvents,
|
||||||
|
{
|
||||||
|
cursor: frame.envelope.cursor,
|
||||||
|
event: frame
|
||||||
|
}
|
||||||
|
].slice(-500);
|
||||||
|
} else {
|
||||||
|
streamDiagnostics = [
|
||||||
|
...streamDiagnostics,
|
||||||
|
{
|
||||||
|
code: frame.diagnostic.code,
|
||||||
|
severity: 'warning',
|
||||||
|
message: frame.diagnostic.message
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
streamDiagnostics = [
|
||||||
|
...streamDiagnostics,
|
||||||
|
{
|
||||||
|
code: 'worker_observation_frame_invalid',
|
||||||
|
severity: 'warning',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (token === reloadToken) {
|
||||||
|
streamState = 'error';
|
||||||
|
streamDiagnostics = [
|
||||||
|
...streamDiagnostics,
|
||||||
|
{
|
||||||
|
code: 'worker_observation_ws_error',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Backend observation WebSocket failed; transcript refresh remains available.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (token === reloadToken && streamState !== 'error') {
|
||||||
|
streamState = 'closed';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
|
||||||
|
return groups.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticsToText(items: Diagnostic[]): string {
|
||||||
|
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineClass(line: ConsoleLine): string {
|
||||||
|
return line.error ? 'error' : line.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadWorkspace();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const target = consoleTarget;
|
||||||
|
observedEvents = [];
|
||||||
|
streamDiagnostics = [];
|
||||||
|
advanceReloadToken();
|
||||||
|
void loadConsoleData(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => connectObservation(worker, reloadToken, consoleTarget));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Worker Console · Yoi Workspace</title>
|
||||||
|
<meta name="description" content="Worker attach console through Workspace Backend APIs" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="workspace-layout">
|
||||||
|
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
|
||||||
|
|
||||||
|
<main class="shell console-shell worker-console-shell">
|
||||||
|
<section class="console-header card">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Worker attach Console</p>
|
||||||
|
<h2>{worker?.label ?? workerId}</h2>
|
||||||
|
<p class="section-note">
|
||||||
|
Target authority is <code>runtime_id</code> + <code>worker_id</code>. Browser traffic uses Workspace Backend Worker APIs only;
|
||||||
|
Runtime endpoints, credentials, socket paths, and session paths are not exposed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="console-status-pill" class:warn={streamState !== 'open'}>
|
||||||
|
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="console-grid">
|
||||||
|
<article class="card transcript-card worker-transcript-card">
|
||||||
|
<header class="transcript-toolbar">
|
||||||
|
<div>
|
||||||
|
<h3>Transcript and protocol events</h3>
|
||||||
|
{#if projection.status || projection.usage}
|
||||||
|
<p class="section-note">
|
||||||
|
{#if projection.status}status: {projection.status}{/if}
|
||||||
|
{#if projection.status && projection.usage} · {/if}
|
||||||
|
{#if projection.usage}usage: {projection.usage}{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondary-button" onclick={refreshConsole}>Refresh</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if workerError}
|
||||||
|
<p class="error">{workerError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if transcriptError}
|
||||||
|
<p class="error">{transcriptError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if transcriptOnly}
|
||||||
|
<p class="section-note degrade-note">{transcriptOnly}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if lines.length === 0}
|
||||||
|
<p>No transcript items or observation events are available for this Worker yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="transcript worker-transcript">
|
||||||
|
{#each lines as item}
|
||||||
|
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
|
||||||
|
<div class="message-heading">
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<small>{item.source}{item.streaming ? ' · streaming' : ''}</small>
|
||||||
|
</div>
|
||||||
|
<pre>{item.body || '—'}</pre>
|
||||||
|
{#if item.detail || item.cursor}
|
||||||
|
<details class="message-detail">
|
||||||
|
<summary>metadata</summary>
|
||||||
|
{#if item.detail}<p>{item.detail}</p>{/if}
|
||||||
|
{#if item.cursor}<code>{item.cursor}</code>{/if}
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside class="console-side-card card">
|
||||||
|
<h3>Worker detail</h3>
|
||||||
|
{#if worker}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Runtime</dt>
|
||||||
|
<dd><code>{worker.runtime_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Worker</dt>
|
||||||
|
<dd><code>{worker.worker_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Host</dt>
|
||||||
|
<dd><code>{worker.host_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Role / profile</dt>
|
||||||
|
<dd>{worker.role ?? 'unknown'} / {worker.profile ?? 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Workspace</dt>
|
||||||
|
<dd>{worker.workspace.visibility} · {worker.workspace.identity}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Implementation</dt>
|
||||||
|
<dd>{worker.implementation.kind} · {worker.implementation.display_hint}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<details class="metadata-details">
|
||||||
|
<summary>Capabilities</summary>
|
||||||
|
<ul>
|
||||||
|
<li>input: {worker.capabilities.can_accept_input ? 'available' : 'unsupported'}</li>
|
||||||
|
<li>stream: {worker.capabilities.can_stream_events ? 'available' : 'unsupported'}</li>
|
||||||
|
<li>bounded transcript: {worker.capabilities.can_read_bounded_transcript ? 'available' : 'unsupported'}</li>
|
||||||
|
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
|
||||||
|
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{:else if !workerError}
|
||||||
|
<p>Loading Worker detail…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if diagnostics.length > 0}
|
||||||
|
<details class="metadata-details" open={streamState === 'error'}>
|
||||||
|
<summary>Diagnostics ({diagnostics.length})</summary>
|
||||||
|
<ul>
|
||||||
|
{#each diagnostics as diagnostic}
|
||||||
|
<li>
|
||||||
|
<strong>{diagnostic.severity}</strong>
|
||||||
|
<code>{diagnostic.code}</code>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="console-composer card" onsubmit={sendMessage}>
|
||||||
|
<label for="worker-console-message">Send user input</label>
|
||||||
|
<textarea
|
||||||
|
id="worker-console-message"
|
||||||
|
bind:value={draft}
|
||||||
|
placeholder={worker?.capabilities.can_accept_input ? 'Message this Worker through the Backend input API…' : 'Input is unsupported for this Worker'}
|
||||||
|
disabled={!worker?.capabilities.can_accept_input || sending}
|
||||||
|
></textarea>
|
||||||
|
<div class="composer-actions">
|
||||||
|
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
||||||
|
{#if sendError}<p class="error">{sendError}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function load(
|
||||||
|
{ params }: { params: { runtimeId: string; workerId: string } },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
runtimeId: params.runtimeId,
|
||||||
|
workerId: params.workerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user