diff --git a/.yoi/tickets/00001KW2GCPYF/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KW2GCPYF/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..2588bfd1 --- /dev/null +++ b/.yoi/tickets/00001KW2GCPYF/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KW2GCPYF/item.md b/.yoi/tickets/00001KW2GCPYF/item.md index 9ae4733a..fca289c5 100644 --- a/.yoi/tickets/00001KW2GCPYF/item.md +++ b/.yoi/tickets/00001KW2GCPYF/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace Worker Consoleを任意Worker attach前提で再設計する' -state: 'queued' +state: 'done' created_at: '2026-06-26T17:42:10Z' -updated_at: '2026-06-26T17:45:40Z' +updated_at: '2026-06-26T18:22:51Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-26T17:45:40Z' diff --git a/.yoi/tickets/00001KW2GCPYF/thread.md b/.yoi/tickets/00001KW2GCPYF/thread.md index c269f35f..62ffb8f7 100644 --- a/.yoi/tickets/00001KW2GCPYF/thread.md +++ b/.yoi/tickets/00001KW2GCPYF/thread.md @@ -30,4 +30,352 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 が残ること。 + +--- + + + +## 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 に進める。 + +--- + + + +## 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. + +--- + + + +## 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 が入るなら置き換え候補。 + +--- + + + +## 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 作成の可能性があるため未実施です。 + +--- + + + +## 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 は既存設計の範囲。 + +--- + + + +## 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 残存確認。ビルド・テスト再実行はファイル生成の可能性があるため未実施。 + +--- + + + +## 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` へ進める。 + +--- + + + +## 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 として未実装。 + +--- + + + +## 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. + --- diff --git a/web/workspace/deno.json b/web/workspace/deno.json index aaa013ca..b9bfb471 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,10 +6,12 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", + "test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, "imports": { + "$lib/": "./src/lib/", "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index 95e19e3b..203d868d 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -816,3 +816,214 @@ cursor: not-allowed; 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; + } +} diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts new file mode 100644 index 00000000..0a7dbd88 --- /dev/null +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -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", + ); +}); diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts new file mode 100644 index 00000000..526e1707 --- /dev/null +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -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); + } +} diff --git a/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts new file mode 100644 index 00000000..20d2e14d --- /dev/null +++ b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts @@ -0,0 +1,91 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; + readTextFile(path: string | URL): Promise; +}; + +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", + ); +}); diff --git a/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte b/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte index 5b13ea25..c68398f5 100644 --- a/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte +++ b/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte @@ -1,5 +1,6 @@ - - diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index 26dc0ad3..10a414d0 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -1,8 +1,15 @@ - - - Companion Console · Yoi Workspace - - - -
- - -
-
-
-

Backend-internal Companion

-

Companion Console

-

- Browser traffic stays behind Workspace API projections. No Worker socket, session path, - runtime credential, or local session file is exposed to the frontend. -

-
-
- {operationState} - {#if status?.worker} - {status.worker.label} - {:else} - worker pending - {/if} -
-
- - {#if status?.transport} -
-
-
Transport
-
{status.transport.kind}
-
-
-
Completion
-
{status.transport.completion}
-
-

{status.transport.limitation}

-
- {/if} - - {#if error || timeoutNotice || diagnostics.length > 0} -
- {#if timeoutNotice} -

{timeoutNotice}

- {/if} - {#if error} -

{error}

- {/if} - {#each diagnostics as diagnostic} -

{diagnostic.code}: {diagnostic.message}

- {/each} -
- {/if} - -
-
-

Transcript

- {transcript?.total_items ?? 0} items -
- {#if messages.length === 0} -

No Companion messages yet. Send a message to exercise the backend boundary.

- {:else} -
    - {#each messages as message (message.sequence)} -
  1. -
    - {message.role} - {message.status} - -
    -

    {message.content}

    -
  2. - {/each} -
- {/if} -
- -
- - -
- {draft.trim().length}/8000 - - - -
-
-
-
diff --git a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte new file mode 100644 index 00000000..6fb7fda6 --- /dev/null +++ b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -0,0 +1,439 @@ + + + + Worker Console · Yoi Workspace + + + +
+ + +
+
+
+

Worker attach Console

+

{worker?.label ?? workerId}

+

+ Target authority is runtime_id + worker_id. Browser traffic uses Workspace Backend Worker APIs only; + Runtime endpoints, credentials, socket paths, and session paths are not exposed. +

+
+
+ {worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState} +
+
+ +
+
+
+
+

Transcript and protocol events

+ {#if projection.status || projection.usage} +

+ {#if projection.status}status: {projection.status}{/if} + {#if projection.status && projection.usage} · {/if} + {#if projection.usage}usage: {projection.usage}{/if} +

+ {/if} +
+ +
+ + {#if workerError} +

{workerError}

+ {/if} + {#if transcriptError} +

{transcriptError}

+ {/if} + {#if transcriptOnly} +

{transcriptOnly}

+ {/if} + + {#if lines.length === 0} +

No transcript items or observation events are available for this Worker yet.

+ {:else} +
    + {#each lines as item} +
  1. +
    + {item.title} + {item.source}{item.streaming ? ' · streaming' : ''} +
    +
    {item.body || '—'}
    + {#if item.detail || item.cursor} +
    + metadata + {#if item.detail}

    {item.detail}

    {/if} + {#if item.cursor}{item.cursor}{/if} +
    + {/if} +
  2. + {/each} +
+ {/if} +
+ + +
+ +
+ + +
+ + {#if sendError}

{sendError}

{/if} +
+
+
+
diff --git a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts new file mode 100644 index 00000000..4da7948a --- /dev/null +++ b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts @@ -0,0 +1,8 @@ +export function load( + { params }: { params: { runtimeId: string; workerId: string } }, +) { + return { + runtimeId: params.runtimeId, + workerId: params.workerId, + }; +} diff --git a/web/workspace/tsconfig.json b/web/workspace/tsconfig.json index 43447105..9d7c4dce 100644 --- a/web/workspace/tsconfig.json +++ b/web/workspace/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, + "allowImportingTsExtensions": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true,