diff --git a/.yoi/tickets/00001KV5W3PJ3/item.md b/.yoi/tickets/00001KV5W3PJ3/item.md index fcc0ff2e..c1d3f997 100644 --- a/.yoi/tickets/00001KV5W3PJ3/item.md +++ b/.yoi/tickets/00001KV5W3PJ3/item.md @@ -1,8 +1,8 @@ --- title: 'Plugin: enforce Plugin permission grants' -state: 'inprogress' +state: 'closed' created_at: '2026-06-15T14:48:59Z' -updated_at: '2026-06-18T13:12:47Z' +updated_at: '2026-06-18T14:24:42Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'permission', 'grant-enforcement', 'capability-boundary', 'tool-execution'] diff --git a/.yoi/tickets/00001KV5W3PJ3/resolution.md b/.yoi/tickets/00001KV5W3PJ3/resolution.md new file mode 100644 index 00000000..341c0a0b --- /dev/null +++ b/.yoi/tickets/00001KV5W3PJ3/resolution.md @@ -0,0 +1,30 @@ +Ticket `00001KV5W3PJ3` is complete. + +Completed implementation: +- Added typed Plugin permission declarations/grants for tool surfaces, tool names/namespaces, `external_write`, and future `host_api.https` / `host_api.fs` boundaries. +- Bound grants to source-qualified package identity, deterministic digest, and exact package version. +- Added fail-closed registration gating in `PluginToolFeature::install`. +- Added independent runtime execution gating in `run_plugin_wasm_tool` before WASM load/execute. +- Added future host API permission boundary checks without implementing actual `https` / `fs` host APIs. +- Added bounded/sanitized denial diagnostics. +- Preserved the existing PreToolCall / Tool permission path; plugin grants are an additional fail-closed gate, not an ambient authority grant. + +Reviewed / merged: +- Implementation commit: `b1ba1599` (`plugin: enforce permission grants`) +- Reviewer result: approve, no blockers. +- Orchestrator merge commit: `94aa3c1d` (`merge: plugin permission grants`) + +Validation in Orchestrator worktree: +- `cargo fmt --check` — passed +- `cargo check -p manifest -p pod` — passed +- `cargo test -p pod plugin -- --nocapture` — passed; 27 passed, 0 failed +- `cargo test -p manifest plugin -- --nocapture` — passed; 17 passed, 0 failed +- `git diff --check` — passed + +Cleanup: +- Stopped Coder Pod `yoi-coder-00001KV5W3PJ3`. +- Stopped Reviewer Pod `yoi-reviewer-00001KV5W3PJ3`. +- Removed child worktree `/home/hare/Projects/yoi/.worktree/00001KV5W3PJ3-plugin-permission-grants`. +- Deleted merged branch `impl/00001KV5W3PJ3-plugin-permission-grants`. + +Root/original workspace promotion was not performed in this step; the completed work is integrated on the Orchestrator branch. \ No newline at end of file diff --git a/.yoi/tickets/00001KV5W3PJ3/thread.md b/.yoi/tickets/00001KV5W3PJ3/thread.md index 91873588..4effc0c2 100644 --- a/.yoi/tickets/00001KV5W3PJ3/thread.md +++ b/.yoi/tickets/00001KV5W3PJ3/thread.md @@ -122,3 +122,268 @@ State handling: - Ticket remains `inprogress` because acceptance and worktree side effects already happened; it is not silently returned to `queued`. --- + + + +## Implementation report + +Implementation resume note: + +Coder Pod の再開を試み、最初の `SpawnPod` は child scope に runtime workspace root `/home/hare/Projects/yoi` の read scope が含まれていなかったため失敗した。 + +Recovery: +- 既存 implementation worktree `/home/hare/Projects/yoi/.worktree/00001KV5W3PJ3-plugin-permission-grants` を再利用。 +- child scope を workspace root read + implementation worktree write に狭く設定して再試行。 +- Coder Pod `yoi-coder-00001KV5W3PJ3` の起動に成功。 + +Current status: +- Ticket は `inprogress` のまま。 +- Coder は Plugin permission grants 実装、focused validation、commit、実装報告を行う予定。 +- root/original workspace は触らず、implementation worktree 上で作業するよう指示済み。 + +--- + + + +## Implementation report + +Coder implementation report received: + +Commit: +- `b1ba1599` — `plugin: enforce permission grants` + +Summary: +- typed plugin permission declarations/grants を追加。 + - `surfaces.tool` + - specific tool names + - tool namespaces + - `external_write` + - future `host_api.https` / `host_api.fs` +- grant binding を source-qualified package id / deterministic digest / exact package version に結合。 +- registration gating を `PluginToolFeature::install` に実装。 + - ungranted / unrequested plugin tools は登録されない。 + - partial grant では authorized tools のみ登録し、skipped tools は bounded diagnostics。 +- runtime execution gating を `run_plugin_wasm_tool` に実装。 + - WASM 読み込み/実行前に binding と requested/granted tool authority を再確認。 +- future host API boundary として `validate_wasm_imports` が `yoi:https` / `yoi:fs` imports を認識し、non-implemented boundary error の前に permission check。 +- actual `https` / `fs` host API は実装していない。 +- `external_write` を plugin tool manifest metadata に追加し、requested+granted の場合のみ許可。 +- Existing PreToolCall/tool permission path は変更せず、plugin grant checks を追加の fail-closed gate とした。 + +Files changed: +- `crates/manifest/src/plugin.rs` +- `crates/pod/src/feature/plugin.rs` +- `crates/pod/src/pod.rs` + +Validation reported by Coder: +- `cargo fmt --check` +- `cargo check -p manifest -p pod` +- `cargo test -p pod plugin -- --nocapture` — 27 passed +- `cargo test -p manifest plugin -- --nocapture` — 17 passed +- `git diff --check` + +Orchestrator evidence checked: +- Pod output を確認。 +- implementation worktree は clean。 +- commit `b1ba1599` が branch `impl/00001KV5W3PJ3-plugin-permission-grants` の HEAD。 +- diff stat: `crates/manifest/src/plugin.rs`, `crates/pod/src/feature/plugin.rs`, `crates/pod/src/pod.rs` に 575 insertions / 19 deletions。 + +Routing classification: review_needed + +Next action: +- recorded intent / invariants / acceptance criteria に照らして、read-only Reviewer Pod で外部レビューする。 +- 特に fail-closed grant enforcement、source/digest/version binding、no ambient authority、external_write/effect metadata、bounded diagnostics、existing PreToolCall path との整合性を確認する。 + +--- + + + +## Plan + +Review dispatch: + +Reviewer Pod `yoi-reviewer-00001KV5W3PJ3` を read-only scope で起動した。 + +Review target: +- worktree: `/home/hare/Projects/yoi/.worktree/00001KV5W3PJ3-plugin-permission-grants` +- branch: `impl/00001KV5W3PJ3-plugin-permission-grants` +- commit: `b1ba1599` + +Reviewer focus: +- recorded intent / invariants / acceptance criteria に対する適合性。 +- registration / execution の fail-closed grant enforcement。 +- source-qualified identity / digest / version binding。 +- unrelated grant / digest mismatch / missing surface/tool/external_write / unknown kind の拒否。 +- `external_write` と Tool effect / PreToolCall path の整合性。 +- future `https` / `fs` host API permission boundary が ambient authority を作っていないこと。 +- bounded safe diagnostics。 + +Next: +- Reviewer output を確認し、approve なら Ticket review に記録して merge-ready 判断へ進む。request_changes なら Coder に修正依頼する。 + +--- + + + +## Review: approve + +Review result: approve + +対象: +- Ticket: `00001KV5W3PJ3` +- branch: `impl/00001KV5W3PJ3-plugin-permission-grants` +- commit: `b1ba1599` (`plugin: enforce permission grants`) + +No blockers found. + +Review / validation performed: +- review target branch/head が `impl/00001KV5W3PJ3-plugin-permission-grants` at `b1ba1599` であることを確認。 +- merge-base `a984f580` との差分を確認。 +- changed files は以下に限定: + - `crates/manifest/src/plugin.rs` + - `crates/pod/src/feature/plugin.rs` + - `crates/pod/src/pod.rs` +- `git diff --check` against merge-base を実行し、whitespace/check failure なし。 +- Review boundary が read-only/static review のため cargo validation は再実行せず、Coder reported validation を確認。 + +Evidence: +- typed permission/grant model が追加されている。 + - `PluginGrantConfig` は non-empty grants を source-qualified id、digest、exact version に binding し、missing/mismatched binding では fail する。 + - permission variants は `surfaces.*`、tool names/namespaces、`external_write`、future `host_api.https/fs` を含む。 + - `PluginToolManifest.external_write` は explicit metadata として追加され、matching request+grant を要求する設計。 +- grant binding は resolution 時に enforcement され、mismatch では `Grant` diagnostic と no resolved record になる。 +- registration は fail-closed。 + - `PluginToolFeature::install` が tool 登録前に `authorize_plugin_tool` を呼び、denied tool は bounded diagnostic として skip する。 + - `authorize_plugin_tool` は requested+granted `surfaces.tool`、tool permission/name/namespace、必要時 `external_write` を要求する。 +- execution も独立して fail-closed。 + - `run_plugin_wasm_tool` が WASM read/load/execute 前に manifest tool を再確認し、`authorize_plugin_tool` を再実行する。 +- future host API は実装せずに permission boundary を model 化。 + - `authorize_plugin_host_api` は requested+granted host API permission を要求してから `host_api.* is not implemented` を返す。 + - `validate_wasm_imports` は `yoi:https` / `yoi:fs` imports を authorization path に通してから unsupported module を reject する。 +- denial diagnostics は bounded/sanitized。 + - `bounded_message` が 512 bytes に truncation し、newline/tab 以外の control characters を除去する。 +- Existing Tool / PreToolCall path と矛盾していない。 + - granted plugin tools は normal `ToolRegistry` / `PreToolCall` policy path に入る。 + +Test coverage evidence in diff: +- no grant denies registration and runtime execution。 +- specific grant registers only intended tool。 +- unrelated package/digest/version grants do not authorize。 +- requested surface/tool/external_write missing denies。 +- future host API permissions checked before unimplemented boundary。 +- exact package identity/digest/version binding and mismatch cases。 +- unknown permission kind fails at manifest parse boundary。 + +Residual note: +- `external_write` effect metadata は broader `ToolMeta` public API effect field ではなく plugin manifest/tool metadata level で表現されている。Ticket の escalation condition が broad PreToolCall/Tool metadata redesign を要求していたため、この slice では implemented permission gate として許容可能。 + +--- + + + +## Implementation report + +Merge / validation report: + +Implementation branch `impl/00001KV5W3PJ3-plugin-permission-grants` を Orchestrator worktree の `orchestration` branch に merge した。 + +Merge: +- merge commit: `94aa3c1d` — `merge: plugin permission grants` +- implementation commit: `b1ba1599` — `plugin: enforce permission grants` +- changed files: + - `crates/manifest/src/plugin.rs` + - `crates/pod/src/feature/plugin.rs` + - `crates/pod/src/pod.rs` + +Validation in Orchestrator worktree: +- `cargo fmt --check` — passed +- `cargo check -p manifest -p pod` — passed +- `cargo test -p pod plugin -- --nocapture` — passed; 27 passed, 0 failed +- `cargo test -p manifest plugin -- --nocapture` — passed; 17 passed, 0 failed +- `git diff --check` — passed + +Review: +- Reviewer `yoi-reviewer-00001KV5W3PJ3` returned `approve` with no blockers. + +Outcome: +- Recorded requirements are satisfied. +- Implementation is merged into the Orchestrator branch and validated. +- Ticket is ready for done/close processing under standing merge/close completion authority. Root/original workspace promotion is not performed in this step. + +--- + + + +## Implementation report + +Cleanup report: + +Plugin permission grants Ticket の implementation/review Pods と child worktree を cleanup した。 + +Stopped Pods: +- `yoi-coder-00001KV5W3PJ3` +- `yoi-reviewer-00001KV5W3PJ3` + +Removed child implementation worktree: +- `/home/hare/Projects/yoi/.worktree/00001KV5W3PJ3-plugin-permission-grants` + +Deleted merged implementation branch: +- `impl/00001KV5W3PJ3-plugin-permission-grants` at `b1ba1599` + +Orchestrator branch remains the integration authority for this completed work. Root/original workspace promotion was not performed in this step. + +--- + + + +## State changed + +Implementation commit `b1ba1599` was reviewed and approved, merged into the Orchestrator branch as `94aa3c1d`, validated in the Orchestrator worktree, and child implementation resources were cleaned up. Requirements and acceptance criteria are satisfied for this Ticket. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Ticket `00001KV5W3PJ3` is complete. + +Completed implementation: +- Added typed Plugin permission declarations/grants for tool surfaces, tool names/namespaces, `external_write`, and future `host_api.https` / `host_api.fs` boundaries. +- Bound grants to source-qualified package identity, deterministic digest, and exact package version. +- Added fail-closed registration gating in `PluginToolFeature::install`. +- Added independent runtime execution gating in `run_plugin_wasm_tool` before WASM load/execute. +- Added future host API permission boundary checks without implementing actual `https` / `fs` host APIs. +- Added bounded/sanitized denial diagnostics. +- Preserved the existing PreToolCall / Tool permission path; plugin grants are an additional fail-closed gate, not an ambient authority grant. + +Reviewed / merged: +- Implementation commit: `b1ba1599` (`plugin: enforce permission grants`) +- Reviewer result: approve, no blockers. +- Orchestrator merge commit: `94aa3c1d` (`merge: plugin permission grants`) + +Validation in Orchestrator worktree: +- `cargo fmt --check` — passed +- `cargo check -p manifest -p pod` — passed +- `cargo test -p pod plugin -- --nocapture` — passed; 27 passed, 0 failed +- `cargo test -p manifest plugin -- --nocapture` — passed; 17 passed, 0 failed +- `git diff --check` — passed + +Cleanup: +- Stopped Coder Pod `yoi-coder-00001KV5W3PJ3`. +- Stopped Reviewer Pod `yoi-reviewer-00001KV5W3PJ3`. +- Removed child worktree `/home/hare/Projects/yoi/.worktree/00001KV5W3PJ3-plugin-permission-grants`. +- Deleted merged branch `impl/00001KV5W3PJ3-plugin-permission-grants`. + +Root/original workspace promotion was not performed in this step; the completed work is integrated on the Orchestrator branch. + +--- diff --git a/.yoi/tickets/00001KVDETSN6/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVDETSN6/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..4ea4ad7f --- /dev/null +++ b/.yoi/tickets/00001KVDETSN6/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260618-135757-1","ticket_id":"00001KVDETSN6","kind":"accepted_plan","accepted_plan":{"summary":"Panel startup latency の主 metric を user-visible dashboard content ready に置き直し、live-like fixture / expected dashboard snapshot / slow-source breakdown を追加し、必要な latency 改善を行う。first frame や単一 Ticket row readiness を主 evidence として扱わない。","branch":"impl/00001KVDETSN6-panel-dashboard-content-ready","worktree":"/home/hare/Projects/yoi/.worktree/00001KVDETSN6-panel-dashboard-content-ready","role_plan":"Orchestrator は専用 implementation worktree を作成し、Coder をその worktree への narrow write scope で起動する。既存 Plugin permission grants Coder とは主対象が異なり、Panel/E2E/TUI harness 側の変更として並行可能。Reviewer は実装報告後に read-only で確認する。"},"author":"yoi-orchestrator","at":"2026-06-18T13:57:57Z"} diff --git a/.yoi/tickets/00001KVDETSN6/item.md b/.yoi/tickets/00001KVDETSN6/item.md index 4e07b528..298326a3 100644 --- a/.yoi/tickets/00001KVDETSN6/item.md +++ b/.yoi/tickets/00001KVDETSN6/item.md @@ -1,8 +1,8 @@ --- title: 'Panel startup latency をユーザー目線の dashboard content ready 基準で計測・改善する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-18T13:30:51Z' -updated_at: '2026-06-18T13:55:08Z' +updated_at: '2026-06-18T14:46:51Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel', 'e2e', 'startup-latency', 'user-visible-readiness', 'dashboard-content', 'profiling'] diff --git a/.yoi/tickets/00001KVDETSN6/thread.md b/.yoi/tickets/00001KVDETSN6/thread.md index 224117f9..a35737bc 100644 --- a/.yoi/tickets/00001KVDETSN6/thread.md +++ b/.yoi/tickets/00001KVDETSN6/thread.md @@ -13,4 +13,415 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Panel Queue により、この Ticket は Orchestrator routing 対象として明示許可された。 +- 主目的は Panel startup latency の測定基準を、first frame / 単一 Ticket row readiness ではなく、ユーザー目線の dashboard content ready に揃えること。 +- 要件、非目標、validation、escalation 条件が実装可能な粒度で揃っており、残る不確実性は Panel/E2E/TUI harness 近傍の bounded implementation investigation に閉じる。 +- `depends_on` / incoming `blocks` の未解決 blocker は見当たらない。関連 Ticket は完了済みまたは context link として扱える。 +- 既存 in-progress Ticket `00001KV5W3PJ3` は Plugin permission grants 領域で、今回の主作業面は Panel startup/E2E/TUI harness 側のため、別 worktree での並行実装は conflict risk が低い。 + +Evidence checked: +- Ticket `00001KVDETSN6` body/thread/artifacts via `TicketShow`。 +- `TicketRelationQuery(00001KVDETSN6)` の relation metadata: blocking acceptance blocker はなし。 +- `TicketOrchestrationPlanQuery(00001KVDETSN6)`: 既存 plan record はなし。今回 `accepted_plan` を記録済み。 +- 関連 Ticket `00001KV62PF32`, `00001KV5MRH6D`, `00001KV5D7MG5` の状態: done/closed context として確認。 +- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration` の git state と既存 worktree/branch: matching implementation branch/worktree はなし。 +- Code map: `panel_ready`, `rows_rendered`, startup latency / fixture readiness 周辺の既存実装・テスト候補を grep で確認。 +- Visible Pods: 既存 Coder `yoi-coder-00001KV5W3PJ3` は別 Ticket 用。今回の worktree / branch / scope を分離できる。 + +IntentPacket: + +Intent: +- Panel startup latency の主な evidence を、ユーザーが dashboard として意味ある内容を見られる状態へ合わせる。 +- live-like fixture と expected dashboard content snapshot を使い、Ticket/Pod/claim など代表 dashboard data が描画・利用可能になるまでを測定できるようにする。 +- 測定結果から遅延源を分解し、必要な範囲で startup/readiness 改善を行う。 + +Binding decisions / invariants: +- `panel_ready` や first frame は主 UX metric にしない。必要なら補助 metric として残す。 +- `rows_rendered`/単一 Ticket row readiness だけで dashboard content ready と見なさない。 +- E2E/fixture はユーザーに見える dashboard content を代表すること。空画面や trivial row だけの readiness は不可。 +- Panel は scheduler/backend ではなく local-file-first view である、という既存設計を変えない。 +- Mouse/input semantics や Panel queue/close/review workflow semantics をこの Ticket で広げない。 +- root/original workspace は操作せず、Orchestrator worktree から作成した child implementation worktree だけで実装する。 + +Requirements / acceptance criteria: +- Dashboard content ready を測る fixture/test harness または equivalent な計測 surface が追加される。 +- Expected dashboard content snapshot / assertion があり、ユーザーに意味のある複数種の dashboard rows/data が ready 条件に含まれる。 +- Startup latency 出力に first frame と dashboard content ready の違い、または slow-source breakdown が分かる evidence がある。 +- 既存 Panel startup regression test / benchmark 相当が新しい基準に合わせて更新される。 +- 改善実装を入れる場合は、semantic shortcut ではなく実際の readiness path の遅延削減であること。 + +Implementation latitude: +- 既存 Panel test/fixture structure を調査し、最小の fixture/harness 拡張で dashboard content ready を表現してよい。 +- Metric 名、structured output field 名、test helper の分割は既存コードに合わせてよい。 +- 遅延改善は、測定で見えた局所的な loading/readiness bottleneck に限定してよい。 + +Escalate if: +- Panel architecture を scheduler/backend 化する必要が出る。 +- Dashboard ready の定義に product/UX 判断が必要な未記録の分岐がある。 +- Terminal/PTY 実 E2E の新規大規模設計が必要になる。 +- Existing Ticket lifecycle / queue semantics を変更しないと達成できない。 + +Validation: +- Focused Panel startup / fixture / snapshot tests。 +- Relevant `cargo test` / `cargo check`。 +- `cargo fmt --check`。 +- `git diff --check`。 +- `nix build .#yoi` は dependency/Cargo.lock/package-source-filter 変更時のみ。 + +Current code map: +- Panel startup metric / rows readiness / test fixtures around matches for `panel_ready`, `rows_rendered`, `startup latency`, dashboard fixture readiness. +- Likely crates: `crates/tui` and related integration/E2E harness files. +- Avoid unrelated Plugin permission grant worktree and root/original workspace. + +Critical risks / reviewer focus: +- Metric rename/additionが実際の UX readiness を測らず名前だけ変えていないか。 +- Fixture が live-like で、Ticket/Pod/claim など dashboard content を代表しているか。 +- Slow-source breakdown が regression triage に使える bounded output か。 +- Startup performance 改善が semantics を壊す shortcut ではないか。 +- Existing Panel behavior / queue semantics / row selection semantics を accidental に変更していないか。 + +Next action: +- `queued -> inprogress` を記録し、Orchestrator worktree の tracked Ticket records を commit してから、専用 implementation worktree を作成し Coder Pod を narrow write scope で起動する。 + +--- + + + +## State changed + +Routing decision と accepted implementation plan を記録済み。Ticket body/thread、relations、OrchestrationPlan、関連 Ticket、Orchestrator worktree、visible Pods、既存 branch/worktree を確認し、blocking relation / conflict / missing planning decision は見つからなかった。Panel startup dashboard-content-ready work は既存 Plugin permission grants work と主対象が異なり、別 worktree/branch/scope で並行可能なため、implementation side effects の前に acceptance を記録する。 + +--- + + + +## Implementation report + +Implementation start note: + +`queued -> inprogress` acceptance、accepted plan の記録、Orchestrator worktree での Ticket record commit 後に、専用 implementation worktree と Coder Pod を起動した。 + +Worktree: +- `/home/hare/Projects/yoi/.worktree/00001KVDETSN6-panel-dashboard-content-ready` +- branch: `impl/00001KVDETSN6-panel-dashboard-content-ready` +- base: Orchestrator branch commit `3b487944` + +Coder Pod: +- `yoi-coder-00001KVDETSN6` + +Scope / boundaries: +- child runtime workspace root は read scope。 +- implementation worktree は write scope。 +- root/original workspace と Orchestrator worktree へは書き込まないよう指示済み。 +- `.yoi/memory`、runtime/log/lock/local/secret-like `.yoi` paths は編集しないよう指示済み。 + +Next: +- Coder は dashboard content ready metric / live-like fixture / slow-source breakdown / focused validation を実装し、commit と実装報告を返す予定。 + +--- + + + +## Implementation report + +Coder implementation report received: + +Commit: +- `fc1ee5bb` — `tui: measure panel dashboard readiness` + +Summary: +- Panel startup E2E に dashboard-content-ready metric を追加。 +- fixture に複数 Ticket、Pod metadata、Companion claim、orchestration overlay を追加。 +- Expected dashboard content snapshot / assertions を導入し、loading/partial/single-row では ready 判定しないようにした。 +- stdout JSON に `dashboard_content_ready`, `startup_readiness_checks`, `startup_sources` を追加。 +- source breakdown と render-source summary を追加。 +- startup regression test を first frame / single-row ではなく dashboard content ready basis に更新。 +- semantic shortcut ではなく expected dashboard row set と overlay/action/status を検証する readiness path として実装。 + +Files changed: +- `crates/tui/tests/e2e/panel.rs` + +Validation reported by Coder: +- `cargo fmt --check` +- `cargo test -p tui --test panel --features e2e -- --nocapture panel_startup_latency_e2e_reports_content_ready_after_rows` +- `cargo test -p tui --test panel --features e2e -- --nocapture panel_startup_latency_e2e_fails_if_expected_dashboard_content_is_missing` +- `cargo test -p tui --test panel --features e2e -- --nocapture panel_startup_latency_e2e_fails_if_orchestration_overlay_is_missing` +- `cargo check -p tui` +- `git diff --check` + +Measured evidence reported by Coder: +- passing content-ready run: + - `panel_ready_ms`: 39 + - `row_ready_ms`: 140 + - `dashboard_content_ready_ms`: 140 +- negative fixtures fail as expected for missing expected dashboard content / missing orchestration overlay。 + +Orchestrator evidence checked: +- Pod output を確認。 +- implementation worktree は clean。 +- commit `fc1ee5bb` が branch `impl/00001KVDETSN6-panel-dashboard-content-ready` の HEAD。 +- diff stat: `crates/tui/tests/e2e/panel.rs` に 385 insertions / 34 deletions。 + +Routing classification: review_needed + +Next action: +- recorded intent / invariants / acceptance criteria に照らして、read-only Reviewer Pod で外部レビューする。 +- 特に dashboard content ready が名前だけの metric ではないこと、fixture が live-like な representative dashboard content を検証していること、single-row readiness だけでは通らないこと、negative fixtures が有効であること、Panel semantics を accidental に変更していないことを確認する。 + +--- + + + +## Plan + +Review dispatch: + +Reviewer Pod `yoi-reviewer-00001KVDETSN6` を read-only scope で起動した。 + +Review target: +- worktree: `/home/hare/Projects/yoi/.worktree/00001KVDETSN6-panel-dashboard-content-ready` +- branch: `impl/00001KVDETSN6-panel-dashboard-content-ready` +- commit: `fc1ee5bb` + +Reviewer focus: +- recorded intent / invariants / acceptance criteria に対する適合性。 +- `dashboard_content_ready` が名前だけの metric ではなく、代表 dashboard content の expected row/content set に基づくこと。 +- first frame / single-row readiness だけでは主 E2E が通らないこと。 +- missing dashboard content / missing orchestration overlay の negative fixtures が意味を持つこと。 +- startup source breakdown が bounded で regression triage に使えること。 +- Existing Panel behavior / workflow semantics を accidental に変更していないこと。 + +Next: +- Reviewer output を確認し、approve なら Ticket review に記録して merge-ready 判断へ進む。request_changes なら Coder に修正依頼する。 + +--- + + + +## Review: request changes + +Review result: request_changes + +対象: +- Ticket: `00001KVDETSN6` +- branch: `impl/00001KVDETSN6-panel-dashboard-content-ready` +- commit: `fc1ee5bb` (`tui: measure panel dashboard readiness`) + +Blockers: + +1. Dashboard content readiness が required Companion / Orchestrator / orchestration overlay content を含んでいない。 + - fixture は 2 件の Ticket と `workspace` / `workspace-orchestrator` の Pod metadata だけを作っている。 + - expected dashboard content は ready Ticket、planning Ticket、Pod name `workspace` のみ。 + - orchestration worktree/overlay fixture setup、expected overlay state、Companion/Orchestrator status assertion が `dashboard_content_ready` に含まれていない。 + - Ticket acceptance criteria の Ticket / Pod / Companion / Orchestrator / overlay elements を代表する expected dashboard snapshot に不足がある。 + +2. Wrong / missing action labels が readiness を通過できる。 + - `ExpectedPanelTicketRow::matches` は row kind、id、title、status のみを確認している。 + - `ExpectedDashboardContent` は `tickets` と `pod_names` のみ。 + - main E2E は ready/planning/pod categories を確認するが、`actionable_rows > 0` や specific action labels を確認していない。 + - acceptance では missing action label / wrong action が失敗することを求めている。 + +3. Missing overlay / missing action / wrong state の committed negative fixtures がない。 + - 既存 negative test は単一 ticket matcher の wrong title / wrong kind に限られる。 + - missing row、wrong state、missing overlay、missing action label が dashboard readiness を失敗させることを示す test/fixture が必要。 + +4. emitted `dashboard_content_ready` event が broad category gate のまま。 + - runtime readiness predicate は `ticket_configured && ready_ticket_rows > 0 && planning_ticket_rows > 0 && pod_rows > 0` 程度。 + - harness 側 expected filter により現在の test では first-frame/single-ticket passing を防いでいるが、event 名自体は overlay/action/header content を検証せず emit され得る。 + - main/exported metric として誤用されやすく、Ticket の expected dashboard snapshot 定義より弱い。 + +5. Slow-source breakdown が requested attribution には粗い。 + - 追加 source は pod list、companion presence/lifecycle、ticket config、orchestrator presence/lifecycle、`workspace_panel.build` など。 + - Ticket は Ticket scan/parsing、orchestration overlay validation/read、Pod metadata scan、socket/status probing、role claim scan、git worktree/branch checks の attribution を求めている。 + - overlay/role/ticket parsing/git checks が `workspace_panel.build` に lump されており、live-latency source の診断には不足。 + +Positive evidence: +- main startup test は `dashboard_content_ready` を待つ形に変更され、`panel_ready` や単一 ready Ticket row だけではない。 +- current main E2E は expected fixture Tickets と expected Pod name を要求するため、first frame / single Ticket row だけでは通らない。 +- diff は E2E instrumentation / harness/tests に閉じており、queue/close/review workflow semantic changes は見当たらない。 +- Reviewer は `git diff --check fc1ee5bb^..fc1ee5bb` を実行し、成功を確認した。 + +Requested changes: +- expected dashboard content に action labels / disabled reasons を追加する。 +- Companion / Orchestrator status と orchestration overlay state の fixture support と assertions を追加する。 +- missing row、wrong state、missing overlay、missing action label の negative tests/fixtures を追加する。 +- `dashboard_content_ready` event 自体が complete snapshot semantics を carry/validate する、または exported/main metric が broad category gate と誤解されない形にする。 +- source breakdown を、少なくとも ticket scan/parsing、overlay validation/read/git checks、pod metadata/status probing、local claim scan が見える程度に分割する。省略する場合は明確な non-action rationale を記録する。 + +--- + + + +## Plan + +Review follow-up dispatch: + +Reviewer `yoi-reviewer-00001KVDETSN6` は `request_changes` を返したため、Ticket review として記録し、既存 Coder Pod `yoi-coder-00001KVDETSN6` に修正依頼を送った。 + +Coder に依頼した主な修正: +- dashboard content readiness に Companion / Orchestrator / orchestration overlay state を含める。 +- expected dashboard content に action labels / disabled reasons 相当を含め、wrong/missing action が readiness を通らないようにする。 +- missing row、wrong state、missing overlay、missing action label の negative tests/fixtures を追加する。 +- `dashboard_content_ready` event / exported main metric が broad category gate と誤解されないよう、complete snapshot semantics を carry/validate するか内部 gate と分離する。 +- slow-source breakdown を ticket scan/parsing、overlay validation/read/git checks、pod metadata/status probing、local claim scan などに分割する。広い設計変更なしに観測できない source は明確な non-action rationale として報告する。 + +Next: +- Coder の修正 commit / validation report を待つ。 +- 修正後、必要に応じて同じ review focus で再レビューする。 + +--- + + + +## Implementation report + +Coder review-fix implementation report received: + +Incremental fix commit: +- `5870251b` — `tui: tighten panel dashboard readiness` + +Summary: +- `dashboard_content_ready` を broad category gate から、full dashboard snapshot を carry/validate する readiness に強化。 +- Snapshot/header assertions を追加。 + - Companion status + - Orchestrator status + - orchestration overlay state + - Ticket action labels + - disabled reasons + - local / overlay Ticket states +- E2E fixture を real git/orchestration worktree overlay を作る形に更新。 + - local ready Ticket は `ready` のまま。 + - orchestration overlay 側で同 Ticket を `inprogress` に進める。 + - visible `ready→prog` row と `Wait` action を期待する。 +- negative coverage を追加。 + - missing expected row + - wrong state + - missing overlay + - missing action label +- source breakdown attribution を拡張。 + - `pod_metadata_status_probe.initial` + - `ticket_config_probe` + - `ticket_config_parse` + - `orchestration_overlay_validation_read_git` + - `ticket_scan_parse` + - `local_claim_scan` + - `pod_row_materialization` + - `workspace_panel.build.total` + +Files changed in incremental fix: +- `crates/tui/src/multi_pod.rs` +- `crates/tui/src/workspace_panel.rs` +- `tests/e2e/src/lib.rs` +- `tests/e2e/tests/panel.rs` + +Coder reported no blockers/escalations. + +Orchestrator evidence checked: +- Pod output を確認。 +- implementation worktree は clean。 +- branch `impl/00001KVDETSN6-panel-dashboard-content-ready` HEAD は `5870251b`。 +- incremental diff stat from `fc1ee5bb..5870251b`: 4 files changed, 709 insertions, 65 deletions。 +- `git diff --check fc1ee5bb..HEAD` passed。 + +Validation re-run by Orchestrator in implementation worktree: +- `cargo fmt --check` — passed +- `cargo check -p tui --features e2e-test` — passed +- `cargo test -p yoi-e2e --features e2e --test panel` — passed; 7 passed, 0 failed +- `git diff --check` — passed + +Routing classification: review_needed + +Next action: +- previous `request_changes` blockers が解消されたか、read-only Reviewer Pod で再レビューする。 + +--- + + + +## Plan + +Review re-dispatch: + +Reviewer Pod `yoi-reviewer-00001KVDETSN6-r2` を read-only scope で起動した。 + +Review target: +- worktree: `/home/hare/Projects/yoi/.worktree/00001KVDETSN6-panel-dashboard-content-ready` +- branch: `impl/00001KVDETSN6-panel-dashboard-content-ready` +- commits: + - `fc1ee5bb` — initial implementation + - `5870251b` — review-fix implementation + +Reviewer focus: +- previous `request_changes` blockers が解消されているか。 +- final `dashboard_content_ready` が Ticket / Pod / Companion / Orchestrator / overlay elements を含む expected dashboard snapshot semantics を持つか。 +- first-frame / single-row readiness だけで main metric が通らないか。 +- missing row / wrong state / missing overlay / missing action label の negative tests が意味を持つか。 +- slow-source breakdown が bounded かつ診断可能か。 +- Panel workflow semantics を accidental に変更していないか。 + +Next: +- Reviewer output を確認し、approve なら merge/validation/close-ready 処理へ進む。request_changes なら blocker を記録して Coder に戻す。 + +--- + + + +## Review: approve + +Review result: approve + +対象: +- Ticket: `00001KVDETSN6` +- branch: `impl/00001KVDETSN6-panel-dashboard-content-ready` +- commits: + - `fc1ee5bb` — `tui: measure panel dashboard readiness` + - `5870251b` — `tui: tighten panel dashboard readiness` + +No remaining blockers found. + +Evidence reviewed: +- `dashboard_content_ready` は broad readiness timestamp ではなく、rendered dashboard snapshot を carry するようになっている。 + - `crates/tui/src/multi_pod.rs` の `PanelE2eDashboardContentReady` が `header`, `rows`, `source_breakdown` を持つ。 + - rendered Ticket rows は `action`, `disabled_reason`, `local_state`, `orchestration_overlay_state` を含み、Ticket / Pod / Companion / Orchestrator / overlay-visible content の validation basis になっている。 +- main E2E readiness wait は first-frame / single-row readiness では通らない。 + - `panel_e2e_dashboard_content_is_ready` が header content、ready/planning/pod categories、action/disabled/overlay fields を含む Ticket rows を要求する。 + - test harness は emitted snapshot を `ExpectedDashboardContent` と照合するため、wrong visible content は metric を満たさない。 +- Companion / Orchestrator / overlay content が coverage に含まれている。 + - `tests/e2e/tests/panel.rs` が `companion_status`, `orchestrator_status`, overlay state, local state, action label, disabled reason を assert する。 + - `tests/e2e/src/lib.rs` が real orchestration worktree overlay を作成し、local state は `ready` のまま overlay state を `inprogress` に進め、visible `ready→prog` / `Wait` row を期待している。 +- Negative coverage は meaningful。 + - missing expected row、wrong state、missing overlay state、missing action label が `snapshot_for_expected` を失敗させる。 + - wrong action labels は `ExpectedPanelTicketRow::matches` が `action` equality を見るため reject される。 +- Slow-source breakdown は改善され、bounded。 + - initial pod metadata/status probing、ticket config probe/parse、orchestration overlay validation/read/git checks、ticket scan/parse、local claim scan、pod row materialization、total workspace panel build が区別されている。 + - `workspace_panel.build.total` は additive component ではなく enclosing total であることが naming から分かる。 +- Panel semantics は preserved と判断。 + - local-ready / overlay-inprogress の場合に disabled `Wait` action になり、stale local state から queue しない。 + - planning rows は expected disabled reason 付き `Clarify` を維持。 + - 新しい assertions は intended user-visible dashboard content/action labels に結びついており、incidental layout/terminal formatting には依存していない。 + +Reviewer validation: +- final branch と `fc1ee5bb..5870251b` focus areas の static review。 +- read-only git/worktree inspection。 +- `git diff --check` over reviewed commits — passed。 + +Orchestrator validation already confirmed: +- `cargo fmt --check` — passed +- `cargo check -p tui --features e2e-test` — passed +- `cargo test -p yoi-e2e --features e2e --test panel` — passed +- `git diff --check` — passed + +Conclusion: +- Previous request_changes blockers are resolved. +- Implementation satisfies the recorded Ticket acceptance criteria and is ready for merge/validation in the Orchestrator worktree. + --- diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index a6b3dd21..b59a824b 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -66,18 +66,111 @@ impl PluginExactVersion { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PluginGrantConfig { - pub tools: Vec, - pub secrets: Vec, - pub filesystem: Vec, - pub network: bool, + /// Source-qualified package id this grant is pinned to, for example `project:example`. + pub id: Option, + /// Exact package version this grant is pinned to. + pub version: Option, + /// Deterministic package digest this grant is pinned to. + pub digest: Option, + /// Explicit capabilities granted for the pinned package identity/version/digest. + pub permissions: Vec, } impl PluginGrantConfig { pub fn is_empty(&self) -> bool { - self.tools.is_empty() - && self.secrets.is_empty() - && self.filesystem.is_empty() - && !self.network + self.permissions.is_empty() + } + + pub fn binding_error( + &self, + identity: &SourceQualifiedPluginId, + digest: &str, + version: &str, + ) -> Option<&'static str> { + if self.permissions.is_empty() { + return None; + } + let Some(grant_id) = &self.id else { + return Some("plugin grant is missing a source-qualified package id binding"); + }; + match SourceQualifiedPluginId::parse(grant_id) { + Ok(grant_identity) if &grant_identity == identity => {} + Ok(_) => return Some("plugin grant package id binding does not match enabled package"), + Err(_) => { + return Some( + "plugin grant package id binding is not a valid source-qualified plugin id", + ); + } + } + let Some(grant_digest) = &self.digest else { + return Some("plugin grant is missing a deterministic digest binding"); + }; + if !digest_matches(grant_digest, digest) { + return Some("plugin grant digest binding does not match enabled package digest"); + } + let Some(grant_version) = &self.version else { + return Some("plugin grant is missing an exact package version binding"); + }; + if !grant_version.matches(version) { + return Some("plugin grant version binding does not match enabled package version"); + } + None + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginPermission { + Surface { surface: PluginSurface }, + Tool { name: String }, + ToolNamespace { namespace: String }, + ExternalWrite, + HostApi { api: PluginHostApi }, +} + +impl PluginPermission { + pub fn label(&self) -> String { + match self { + Self::Surface { surface } => format!("surfaces.{surface}"), + Self::Tool { name } => format!("tool.{name}"), + Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"), + Self::ExternalWrite => "external_write".to_string(), + Self::HostApi { api } => format!("host_api.{api}"), + } + } + + pub fn surface(surface: PluginSurface) -> Self { + Self::Surface { surface } + } + + pub fn tool(name: impl Into) -> Self { + Self::Tool { name: name.into() } + } + + pub fn tool_namespace(namespace: impl Into) -> Self { + Self::ToolNamespace { + namespace: namespace.into(), + } + } + + pub fn host_api(api: PluginHostApi) -> Self { + Self::HostApi { api } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginHostApi { + Https, + Fs, +} + +impl fmt::Display for PluginHostApi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Https => f.write_str("https"), + Self::Fs => f.write_str("fs"), + } } } @@ -190,6 +283,10 @@ pub struct PluginPackageManifest { pub hooks: Vec, #[serde(default)] pub tools: Vec, + /// Permission requests declared by the package. These are requests only; + /// enablement grants must match them before runtime surfaces are exposed. + #[serde(default)] + pub permissions: Vec, } impl PluginPackageManifest { @@ -229,6 +326,11 @@ pub struct PluginToolManifest { pub name: String, pub description: String, pub input_schema: serde_json::Value, + /// Whether this Tool declares side effects outside the model-visible result. + /// The flag does not grant authority; it requires a matching external_write + /// request and grant before registration or execution. + #[serde(default)] + pub external_write: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -561,12 +663,16 @@ pub fn resolve_enabled_plugins( } } - if !enablement.grants.is_empty() { + if let Some(message) = + enablement + .grants + .binding_error(&identity, &package.digest, &package.manifest.version) + { resolution.diagnostics.push( PluginDiagnostic::new( PluginDiagnosticKind::Grant, PluginDiagnosticPhase::Resolution, - "plugin authority grants are not implemented and fail closed", + message, ) .with_source(identity.source) .with_identity(&identity) @@ -1937,6 +2043,93 @@ input_schema = { type = "object", properties = { query = { type = "string" } }, ); } + #[test] + fn typed_permission_grant_binding_resolves_only_exact_package_identity() { + let (report, _) = fixture_with_enabled_plugin(false); + let digest = report.packages[0].digest.clone(); + let exact_grants = PluginGrantConfig { + id: Some("project:example".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some(digest.clone()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }; + let resolution = resolve_enabled_plugins( + &PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".to_string(), + grants: exact_grants, + ..PluginEnablementConfig::default() + }], + ..PluginConfig::default() + }, + &report, + ); + assert!( + resolution.diagnostics.is_empty(), + "{:#?}", + resolution.diagnostics + ); + assert_eq!(resolution.resolved.len(), 1); + + for grants in [ + PluginGrantConfig { + id: Some("project:other".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some(digest.clone()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }, + PluginGrantConfig { + id: Some("project:example".to_string()), + version: Some(PluginExactVersion("0.1.1".to_string())), + digest: Some(digest.clone()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }, + PluginGrantConfig { + id: Some("project:example".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some("sha256:unrelated".to_string()), + permissions: vec![PluginPermission::surface(PluginSurface::Hook)], + }, + ] { + let resolution = resolve_enabled_plugins( + &PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".to_string(), + grants, + ..PluginEnablementConfig::default() + }], + ..PluginConfig::default() + }, + &report, + ); + assert!(resolution.resolved.is_empty()); + assert!( + resolution + .diagnostics + .iter() + .any(|diag| diag.kind == PluginDiagnosticKind::Grant), + "{:#?}", + resolution.diagnostics + ); + } + } + + #[test] + fn unknown_permission_kind_fails_closed_at_manifest_parse_boundary() { + let error = toml::from_str::( + r#"schema_version = 1 +id = "example" +name = "Example" +version = "0.1.0" + +[[permissions]] +kind = "ambient_shell" +"#, + ) + .unwrap_err(); + assert!(error.to_string().contains("ambient_shell"), "{error}"); + } + #[test] fn surface_and_grant_failures_do_not_resolve() { let (report, _) = fixture_with_enabled_plugin(false); @@ -1951,7 +2144,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } }, PluginEnablementConfig { id: "project:example".to_string(), grants: PluginGrantConfig { - filesystem: vec![".".to_string()], + permissions: vec![PluginPermission::surface(PluginSurface::Tool)], ..PluginGrantConfig::default() }, ..PluginEnablementConfig::default() diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index a5eae230..97949a31 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -15,8 +15,8 @@ use llm_worker::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, }; use manifest::plugin::{ - PluginConfig, PluginDiscoveryLimits, PluginSurface, ResolvedPluginRecord, - read_resolved_plugin_runtime_module, + PluginConfig, PluginDiscoveryLimits, PluginHostApi, PluginPermission, PluginSurface, + PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_module, }; use serde_json::Value; @@ -106,6 +106,8 @@ impl FeatureModule for PluginToolFeature { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { validate_declared_tool_names(&self.record)?; let origin = self.origin(); + let mut registered = 0usize; + let mut denied = Vec::new(); for tool in &self.record.manifest.tools { validate_tool_name(&tool.name).map_err(|reason| { FeatureInstallError::Install(format!( @@ -119,6 +121,17 @@ impl FeatureModule for PluginToolFeature { self.record.identity, tool.name )) })?; + if let Err(error) = authorize_plugin_tool(&self.record, tool) { + let message = format!( + "plugin `{}` tool `{}` registration denied: {}", + self.record.identity, + tool.name, + error.bounded_message() + ); + context.diagnostics().warning(message.clone()); + denied.push(message); + continue; + } context.tools().register(ToolContribution::new( tool.name.clone(), plugin_wasm_tool_definition( @@ -129,11 +142,128 @@ impl FeatureModule for PluginToolFeature { origin.clone(), ), ))?; + registered += 1; + } + if registered == 0 && !denied.is_empty() { + let summary = if denied.len() == 1 { + denied.remove(0) + } else { + format!( + "{} plugin tool registrations denied; first denial: {}", + denied.len(), + denied[0] + ) + }; + return Err(FeatureInstallError::Install(bounded_message(summary))); } Ok(()) } } +#[derive(Debug)] +struct PluginPermissionError(String); + +impl PluginPermissionError { + fn bounded_message(&self) -> String { + bounded_message(&self.0) + } +} + +fn authorize_plugin_tool( + record: &ResolvedPluginRecord, + tool: &PluginToolManifest, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + require_permission( + &record.manifest.permissions, + &PluginPermission::surface(PluginSurface::Tool), + "requested surfaces.tool permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::surface(PluginSurface::Tool), + "granted surfaces.tool permission is missing", + )?; + if !permission_allows_tool(&record.manifest.permissions, &tool.name) { + return Err(PluginPermissionError(format!( + "requested tool permission for `{}` is missing", + tool.name + ))); + } + if !permission_allows_tool(&record.grants.permissions, &tool.name) { + return Err(PluginPermissionError(format!( + "granted tool permission for `{}` is missing", + tool.name + ))); + } + if tool.external_write { + require_permission( + &record.manifest.permissions, + &PluginPermission::ExternalWrite, + "requested external_write permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::ExternalWrite, + "granted external_write permission is missing", + )?; + } + Ok(()) +} + +fn authorize_plugin_host_api( + record: &ResolvedPluginRecord, + api: PluginHostApi, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + let permission = PluginPermission::host_api(api); + require_permission( + &record.manifest.permissions, + &permission, + &format!("requested host_api.{api} permission is missing"), + )?; + require_permission( + &record.grants.permissions, + &permission, + &format!("granted host_api.{api} permission is missing"), + )?; + Err(PluginPermissionError(format!( + "host_api.{api} is not implemented" + ))) +} + +fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> { + if let Some(message) = + record + .grants + .binding_error(&record.identity, &record.digest, &record.manifest.version) + { + return Err(PluginPermissionError(message.to_string())); + } + Ok(()) +} + +fn require_permission( + permissions: &[PluginPermission], + expected: &PluginPermission, + missing_message: &str, +) -> Result<(), PluginPermissionError> { + if permissions.iter().any(|permission| permission == expected) { + return Ok(()); + } + Err(PluginPermissionError(missing_message.to_string())) +} + +fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> bool { + permissions.iter().any(|permission| match permission { + PluginPermission::Tool { name } => name == tool_name, + PluginPermission::ToolNamespace { namespace } => { + !namespace.is_empty() && tool_name.starts_with(namespace) + } + _ => false, + }) +} + const PLUGIN_WASM_HOST_MODULE: &str = "yoi:tool"; const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call"; const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024; @@ -259,6 +389,20 @@ fn run_plugin_wasm_tool( tool_name: String, input: Vec, ) -> Result { + let tool = record + .manifest + .tools + .iter() + .find(|tool| tool.name == tool_name) + .ok_or_else(|| { + PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string()) + })?; + authorize_plugin_tool(&record, tool).map_err(|error| { + PluginWasmError::Module(format!( + "plugin permission denied: {}", + error.bounded_message() + )) + })?; let limits = PluginDiscoveryLimits::default(); let module_bytes = read_resolved_plugin_runtime_module(&record, &limits) .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; @@ -276,7 +420,7 @@ fn run_plugin_wasm_tool( let engine = wasmi::Engine::new(&config); let module = wasmi::Module::new(&engine, &module_bytes[..]) .map_err(|error| PluginWasmError::Module(error.to_string()))?; - validate_wasm_imports(&module)?; + validate_wasm_imports(&record, &module)?; let store_limits = wasmi::StoreLimitsBuilder::new() .memory_size(PLUGIN_WASM_MEMORY_BYTES) @@ -319,8 +463,27 @@ fn run_plugin_wasm_tool( decode_plugin_wasm_output(&store.data().output) } -fn validate_wasm_imports(module: &wasmi::Module) -> Result<(), PluginWasmError> { +fn validate_wasm_imports( + record: &ResolvedPluginRecord, + module: &wasmi::Module, +) -> Result<(), PluginWasmError> { for import in module.imports() { + if import.module() == "yoi:https" { + authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + } + if import.module() == "yoi:fs" { + authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + } if import.module() != PLUGIN_WASM_HOST_MODULE { return Err(PluginWasmError::Module(format!( "unsupported import module `{}`; only `{}` is available", @@ -752,8 +915,9 @@ fn is_supported_schema_keyword(key: &str) -> bool { mod tests { use super::*; use manifest::plugin::{ - PluginDiscoveryOptions, PluginEnablementConfig, PluginPackageManifest, - PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup, + PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig, + PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId, + resolve_plugin_config_for_startup, }; use serde_json::json; use std::fs; @@ -765,6 +929,7 @@ mod tests { name: name.into(), description: format!("{name} tool"), input_schema: json!({"type":"object","properties":{},"additionalProperties":false}), + external_write: false, } } @@ -777,6 +942,7 @@ mod tests { tools: Vec, ) -> ResolvedPluginRecord { let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap(); + let permissions = tool_permissions(&tools); ResolvedPluginRecord { identity: parsed_identity.clone(), source: parsed_identity.source, @@ -794,13 +960,29 @@ mod tests { runtime: None, hooks: Vec::new(), tools, + permissions: permissions.clone(), }, enabled_surfaces: vec![PluginSurface::Tool], - grants: manifest::plugin::PluginGrantConfig::default(), + grants: PluginGrantConfig { + id: Some(parsed_identity.to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some("sha256:abc".to_string()), + permissions, + }, config: None, } } + fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec { + let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; + permissions.extend( + tools + .iter() + .map(|tool| PluginPermission::tool(tool.name.clone())), + ); + permissions + } + fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize { report .reports @@ -818,6 +1000,20 @@ mod tests { }) } + fn install_plugin_record( + record: ResolvedPluginRecord, + ) -> ( + super::super::FeatureRegistryInstallReport, + Vec, + ) { + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + let report = super::super::FeatureRegistryBuilder::default() + .with_module(PluginToolFeature::new(record)) + .install_into_pending(&mut pending, &mut hooks); + (report, pending) + } + #[test] fn rejects_invalid_root_schema() { let schema = json!({"type":"string"}); @@ -942,6 +1138,146 @@ mod tests { assert_eq!(origin.surface, "tool"); } + #[test] + fn no_grant_denies_plugin_tool_registration_and_runtime_execution() { + let mut record = record(vec![tool("PluginSearch")]); + record.grants = PluginGrantConfig::default(); + + let (report, pending) = install_plugin_record(record.clone()); + assert!(pending.is_empty()); + assert!(has_diagnostic(&report, "registration denied")); + assert!(has_diagnostic( + &report, + "granted surfaces.tool permission is missing" + )); + + let error = run_plugin_wasm_tool(record, "PluginSearch".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); + assert!(error.contains("plugin permission denied"), "{error}"); + assert!( + error.contains("granted surfaces.tool permission is missing"), + "{error}" + ); + assert!(error.len() < 700, "{error}"); + } + + #[test] + fn specific_tool_grant_registers_only_intended_plugin_tool() { + let mut record = record(vec![tool("PluginAllowed"), tool("PluginDenied")]); + record.grants.permissions = vec![ + PluginPermission::surface(PluginSurface::Tool), + PluginPermission::tool("PluginAllowed"), + ]; + + let (report, pending) = install_plugin_record(record); + assert_eq!(pending.len(), 1); + let (meta, _) = pending[0](); + assert_eq!(meta.name, "PluginAllowed"); + assert_eq!(report.installed_tool_names(), vec!["PluginAllowed"]); + assert!(has_diagnostic( + &report, + "granted tool permission for `PluginDenied` is missing" + )); + } + + #[test] + fn grant_binding_mismatches_do_not_authorize_plugin_tool() { + let mut unrelated = record(vec![tool("PluginSearch")]); + unrelated.grants.id = Some("project:other".to_string()); + let error = authorize_plugin_tool(&unrelated, &unrelated.manifest.tools[0]) + .unwrap_err() + .bounded_message(); + assert!( + error.contains("package id binding does not match"), + "{error}" + ); + + let mut bad_digest = record(vec![tool("PluginSearch")]); + bad_digest.grants.digest = Some("sha256:not-the-package".to_string()); + let error = authorize_plugin_tool(&bad_digest, &bad_digest.manifest.tools[0]) + .unwrap_err() + .bounded_message(); + assert!(error.contains("digest binding does not match"), "{error}"); + + let mut bad_version = record(vec![tool("PluginSearch")]); + bad_version.grants.version = Some(PluginExactVersion("9.9.9".to_string())); + let error = authorize_plugin_tool(&bad_version, &bad_version.manifest.tools[0]) + .unwrap_err() + .bounded_message(); + assert!(error.contains("version binding does not match"), "{error}"); + } + + #[test] + fn requested_surface_tool_and_external_write_permissions_are_required() { + let mut missing_surface = record(vec![tool("PluginSearch")]); + missing_surface.manifest.permissions = vec![PluginPermission::tool("PluginSearch")]; + let (report, pending) = install_plugin_record(missing_surface); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "requested surfaces.tool permission is missing" + )); + + let mut missing_tool = record(vec![tool("PluginSearch")]); + missing_tool.manifest.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; + let (report, pending) = install_plugin_record(missing_tool); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "requested tool permission for `PluginSearch` is missing" + )); + + let mut external_tool = tool("PluginWrite"); + external_tool.external_write = true; + let mut missing_external_request = record(vec![external_tool]); + let (report, pending) = install_plugin_record(missing_external_request.clone()); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "requested external_write permission is missing" + )); + + missing_external_request + .manifest + .permissions + .push(PluginPermission::ExternalWrite); + let (report, pending) = install_plugin_record(missing_external_request); + assert!(pending.is_empty()); + assert!(has_diagnostic( + &report, + "granted external_write permission is missing" + )); + } + + #[test] + fn future_host_api_imports_are_permission_checked_before_unimplemented_boundary() { + let (_dir, mut record) = resolved_record_with_wasm(https_import_module()); + let error = run_plugin_wasm_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); + assert!( + error.contains("requested host_api.https permission is missing"), + "{error}" + ); + + record + .manifest + .permissions + .push(PluginPermission::host_api(PluginHostApi::Https)); + record + .grants + .permissions + .push(PluginPermission::host_api(PluginHostApi::Https)); + let error = run_plugin_wasm_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()) + .unwrap_err() + .bounded_message(); + assert!( + error.contains("host_api.https is not implemented"), + "{error}" + ); + } + #[test] fn package_without_enabled_tool_surface_registers_no_schema() { let mut config = PluginConfig::default(); @@ -1176,7 +1512,14 @@ mod tests { resolved.diagnostics ); assert_eq!(resolved.resolved.len(), 1); - (dir, resolved.resolved[0].clone()) + let mut record = resolved.resolved[0].clone(); + record.grants = PluginGrantConfig { + id: Some(record.identity.to_string()), + version: Some(PluginExactVersion(record.version.clone())), + digest: Some(record.digest.clone()), + permissions: tool_permissions(&record.manifest.tools), + }; + (dir, record) } fn write_plugin_package(path: &Path, wasm: &[u8]) { @@ -1192,6 +1535,14 @@ kind = "wasm" entry = "plugin.wasm" abi = "yoi-plugin-wasm-1" +[[permissions]] +kind = "surface" +surface = "tool" + +[[permissions]] +kind = "tool" +name = "PluginEcho" + [[tools]] name = "PluginEcho" description = "Echo plugin tool" @@ -1296,6 +1647,17 @@ input_schema = { type = "object", additionalProperties = true } .unwrap() } + fn https_import_module() -> Vec { + wat::parse_str( + r#"(module + (import "yoi:https" "request" (func $request)) + (memory (export "memory") 1) + (func (export "yoi_tool_call")) + )"#, + ) + .unwrap() + } + fn wat_bytes(bytes: &[u8]) -> String { bytes .iter() diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index b34845b7..9d1c232f 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -5354,6 +5354,7 @@ permission = "read" runtime: None, hooks: vec![], tools: vec![], + permissions: vec![], }, enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], grants: manifest::plugin::PluginGrantConfig::default(), diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 4724c3a1..667291a2 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -44,15 +44,19 @@ use crate::pod_list::{ use crate::role_session_registry::{ PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult, }; +#[cfg(not(feature = "e2e-test"))] +use crate::workspace_panel::build_workspace_panel; +#[cfg(feature = "e2e-test")] +use crate::workspace_panel::build_workspace_panel_with_e2e_timings; use crate::workspace_panel::{ ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus, CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row, - build_workspace_panel, companion_pod_presence, decide_companion_lifecycle, - decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence, - ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name, + companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle, + local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability, + workspace_companion_pod_name, workspace_orchestrator_pod_name, }; const MAX_ENTRIES: usize = 50; @@ -925,14 +929,14 @@ impl PanelRowHitBox { } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRowKey { kind: &'static str, id: String, } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRect { x: u16, y: u16, @@ -941,22 +945,92 @@ struct PanelE2eRect { } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRenderedRow { key: PanelE2eRowKey, title: String, status: Option, action: Option<&'static str>, + disabled_reason: Option, + local_state: Option, + overlay_state: Option, + overlay_detail: Option, rect: PanelE2eRect, } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRowsRendered { selected: Option, + header: PanelE2eDashboardHeader, rows: Vec, } +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eDashboardHeader { + ticket_configured: bool, + companion: Option, + orchestrator: Option, + diagnostics: Vec, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eCompanionState { + pod_name: String, + status: &'static str, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eOrchestratorState { + pod_name: String, + status: &'static str, + detail: Option, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eDashboardContentReady { + snapshot: PanelE2eDashboardSnapshot, + categories: PanelE2eDashboardCategories, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eDashboardSnapshot { + header: PanelE2eDashboardHeader, + rows: Vec, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eDashboardCategories { + ticket_rows: usize, + ready_ticket_rows: usize, + planning_ticket_rows: usize, + pod_rows: usize, + actionable_rows: usize, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eSourceTiming { + source: &'static str, + elapsed_ms: u128, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eDashboardSourceBreakdown { + total_elapsed_ms: u128, + sources: Vec, + ticket_rows: usize, + pod_rows: usize, + diagnostics: usize, +} + #[cfg(feature = "e2e-test")] fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { match key { @@ -992,6 +1066,76 @@ fn panel_e2e_rect(rect: Rect) -> PanelE2eRect { } } +#[cfg(feature = "e2e-test")] +fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashboardCategories { + PanelE2eDashboardCategories { + ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(), + ready_ticket_rows: rows + .iter() + .filter(|row| row.key.kind == "ticket" && row.local_state.as_deref() == Some("ready")) + .count(), + planning_ticket_rows: rows + .iter() + .filter(|row| { + row.key.kind == "ticket" && row.local_state.as_deref() == Some("planning") + }) + .count(), + pod_rows: rows.iter().filter(|row| row.key.kind == "pod").count(), + actionable_rows: rows.iter().filter(|row| row.action.is_some()).count(), + } +} + +#[cfg(feature = "e2e-test")] +fn panel_e2e_dashboard_header(panel: &WorkspacePanelViewModel) -> PanelE2eDashboardHeader { + PanelE2eDashboardHeader { + ticket_configured: panel.header.ticket_configured, + companion: panel + .header + .companion + .as_ref() + .map(|state| PanelE2eCompanionState { + pod_name: state.pod_name.clone(), + status: state.status.label(), + }), + orchestrator: panel + .header + .orchestrator + .as_ref() + .map(|state| PanelE2eOrchestratorState { + pod_name: state.pod_name.clone(), + status: state.status.label(), + detail: state.detail.clone(), + }), + diagnostics: panel.header.diagnostics.clone(), + } +} + +#[cfg(feature = "e2e-test")] +fn panel_e2e_dashboard_content_is_ready( + snapshot: &PanelE2eDashboardSnapshot, + categories: &PanelE2eDashboardCategories, +) -> bool { + snapshot.header.ticket_configured + && snapshot.header.companion.is_some() + && snapshot.header.orchestrator.is_some() + && categories.ready_ticket_rows > 0 + && categories.planning_ticket_rows > 0 + && categories.pod_rows > 0 + && snapshot.rows.iter().any(|row| { + row.key.kind == "ticket" + && row.local_state.as_deref() == Some("ready") + && row.overlay_state.is_some() + && row.action.is_some() + && row.disabled_reason.is_some() + }) + && snapshot.rows.iter().any(|row| { + row.key.kind == "ticket" + && row.local_state.as_deref() == Some("planning") + && row.action.is_some() + && row.disabled_reason.is_some() + }) +} + pub(crate) struct MultiPodApp { pub(crate) list: PodList, pub(crate) panel: WorkspacePanelViewModel, @@ -1010,6 +1154,8 @@ pub(crate) struct MultiPodApp { last_orchestrator_lifecycle_failure: Option, orchestrator_work_set: OrchestratorWorkSet, orchestrator_queue_attention: Option, + #[cfg(feature = "e2e-test")] + emitted_dashboard_content_ready: bool, } impl MultiPodApp { @@ -1046,6 +1192,8 @@ impl MultiPodApp { last_orchestrator_lifecycle_failure: None, orchestrator_work_set: OrchestratorWorkSet::default(), orchestrator_queue_attention: None, + #[cfg(feature = "e2e-test")] + emitted_dashboard_content_ready: false, } } @@ -1355,25 +1503,52 @@ impl MultiPodApp { } #[cfg(feature = "e2e-test")] - fn emit_rows_rendered(&self) { - let rows = self + fn emit_rows_rendered(&mut self) { + let rows: Vec<_> = self .row_hit_boxes .iter() .map(|hit| { let panel_row = self.panel.row(&hit.key); - let (title, status, action) = match panel_row { - Some(row) => ( - row.title.clone(), - Some(row.status.clone()), - row.next_action.map(NextUserAction::label), - ), + let ( + title, + status, + action, + disabled_reason, + local_state, + overlay_state, + overlay_detail, + ) = match panel_row { + Some(row) => { + let ticket = row.ticket.as_ref(); + ( + row.title.clone(), + Some(row.status.clone()), + row.next_action.map(NextUserAction::label), + row.disabled_reason.clone(), + ticket.map(|ticket| ticket.workflow_state.as_str().to_string()), + ticket + .and_then(|ticket| ticket.orchestration_overlay.as_ref()) + .map(|overlay| overlay.workflow_state.as_str().to_string()), + ticket + .and_then(|ticket| ticket.orchestration_overlay.as_ref()) + .map(|overlay| { + format!( + "{}:{}", + overlay.source, + overlay.workflow_state.as_str() + ) + }), + ) + } None => match &hit.key { - PanelRowKey::Pod(name) => (name.clone(), None, None), + PanelRowKey::Pod(name) => { + (name.clone(), None, None, None, None, None, None) + } PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => { - (id.clone(), None, None) + (id.clone(), None, None, None, None, None, None) } PanelRowKey::TicketIntakePod { pod_name, .. } => { - (pod_name.clone(), None, None) + (pod_name.clone(), None, None, None, None, None, None) } }, }; @@ -1382,18 +1557,40 @@ impl MultiPodApp { title, status, action, + disabled_reason, + local_state, + overlay_state, + overlay_detail, rect: panel_e2e_rect(hit.rect), } }) .collect(); + let selected = self.selected_row.as_ref().map(panel_e2e_row_key); + let header = panel_e2e_dashboard_header(&self.panel); crate::e2e_observer::emit( "panel", "rows_rendered", PanelE2eRowsRendered { - selected: self.selected_row.as_ref().map(panel_e2e_row_key), - rows, + selected: selected.clone(), + header: header.clone(), + rows: rows.clone(), }, ); + if !self.emitted_dashboard_content_ready { + let categories = panel_e2e_dashboard_categories(&rows); + let snapshot = PanelE2eDashboardSnapshot { header, rows }; + if panel_e2e_dashboard_content_is_ready(&snapshot, &categories) { + crate::e2e_observer::emit( + "panel", + "dashboard_content_ready", + PanelE2eDashboardContentReady { + snapshot, + categories, + }, + ); + self.emitted_dashboard_content_ready = true; + } + } } fn ensure_selection_visible(&mut self) { @@ -2286,12 +2483,35 @@ async fn load_multi_pod_snapshot( lifecycle_mode: OrchestratorLifecycleMode, ) -> Result { let workspace_root = current_workspace_root(); + #[cfg(feature = "e2e-test")] + let load_started = Instant::now(); + #[cfg(feature = "e2e-test")] + let mut source_timings = Vec::new(); let companion_pod_name = workspace_companion_pod_name(&workspace_root); let list_selected_name = selected_name .clone() .or_else(|| Some(companion_pod_name.clone())); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let mut list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "pod_metadata_status_probe.initial", + elapsed_ms: source_started.elapsed().as_millis(), + }); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let companion_presence = load_exact_companion_pod_presence(&companion_pod_name).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "companion.presence", + elapsed_ms: source_started.elapsed().as_millis(), + }); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let companion = match lifecycle_mode.clone() { OrchestratorLifecycleMode::Ensure { runtime_command } => { ensure_workspace_companion( @@ -2306,17 +2526,48 @@ async fn load_multi_pod_snapshot( observe_workspace_companion(companion_pod_name, companion_presence) } }; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "companion.lifecycle", + elapsed_ms: source_started.elapsed().as_millis(), + }); if companion.reload_pods { + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "pod_metadata_status_probe.after_companion_reload", + elapsed_ms: source_started.elapsed().as_millis(), + }); } + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let config = ticket_config_availability(&workspace_root); + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "ticket_config_probe", + elapsed_ms: source_started.elapsed().as_millis(), + }); + let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root); + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let orchestrator_presence = match &config { TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None, TicketConfigAvailability::Usable => { Some(load_exact_pod_presence(&orchestrator_pod_name).await?) } }; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "orchestrator.presence", + elapsed_ms: source_started.elapsed().as_millis(), + }); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let orchestrator = match lifecycle_mode { OrchestratorLifecycleMode::Ensure { runtime_command } => { ensure_workspace_orchestrator( @@ -2332,14 +2583,63 @@ async fn load_multi_pod_snapshot( observe_workspace_orchestrator(config, orchestrator_pod_name, orchestrator_presence) } }; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "orchestrator.lifecycle", + elapsed_ms: source_started.elapsed().as_millis(), + }); if orchestrator.reload_pods { + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); list = load_pod_list(list_selected_name, MAX_ENTRIES).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "pod_metadata_status_probe.after_orchestrator_reload", + elapsed_ms: source_started.elapsed().as_millis(), + }); } + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); + #[cfg(feature = "e2e-test")] + let (mut panel, panel_source_timings) = + build_workspace_panel_with_e2e_timings(&workspace_root, &list); + #[cfg(not(feature = "e2e-test"))] let mut panel = build_workspace_panel(&workspace_root, &list); panel.header.companion = companion.state; panel.header.diagnostics.extend(companion.diagnostics); panel.header.orchestrator = orchestrator.state; panel.header.diagnostics.extend(orchestrator.diagnostics); + #[cfg(feature = "e2e-test")] + { + source_timings.push(PanelE2eSourceTiming { + source: "workspace_panel.build.total", + elapsed_ms: source_started.elapsed().as_millis(), + }); + source_timings.extend(panel_source_timings.into_iter().map(|timing| { + PanelE2eSourceTiming { + source: timing.source, + elapsed_ms: timing.elapsed_ms, + } + })); + } + + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "panel", + "dashboard_source_breakdown", + PanelE2eDashboardSourceBreakdown { + total_elapsed_ms: load_started.elapsed().as_millis(), + sources: source_timings, + ticket_rows: panel + .rows + .iter() + .filter(|row| row.is_ticket_action()) + .count(), + pod_rows: list.entries.len(), + diagnostics: panel.header.diagnostics.len(), + }, + ); Ok(MultiPodSnapshot { list, panel }) } diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 1c7d59c6..a6441bc6 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(feature = "e2e-test")] +use std::time::Instant; use protocol::PodStatus; use ticket::config::{ @@ -731,6 +733,7 @@ fn git_output(worktree_root: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +#[cfg_attr(feature = "e2e-test", allow(dead_code))] pub(crate) fn build_workspace_panel( workspace_root: &Path, pods: &PodList, @@ -758,6 +761,148 @@ pub(crate) fn build_workspace_panel( build_workspace_panel_with_registry(workspace_root, pods, ®istry) } +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspacePanelE2eSourceTiming { + pub(crate) source: &'static str, + pub(crate) elapsed_ms: u128, +} + +#[cfg(feature = "e2e-test")] +pub(crate) fn build_workspace_panel_with_e2e_timings( + workspace_root: &Path, + pods: &PodList, +) -> (WorkspacePanelViewModel, Vec) { + let mut timings = Vec::new(); + let started = Instant::now(); + let registry = match PanelRegistryStore::default_for_workspace(workspace_root) + .and_then(|store| store.snapshot()) + { + Ok(snapshot) => snapshot, + Err(error) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "local_claim_scan", + elapsed_ms: started.elapsed().as_millis(), + }); + let mut model = WorkspacePanelViewModel::empty(workspace_root); + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Panel local role registry unavailable: {error}" + ))); + return ( + build_workspace_panel_with_registry_model( + model, + workspace_root, + pods, + &PanelRegistrySnapshot::empty(), + ), + timings, + ); + } + }; + timings.push(WorkspacePanelE2eSourceTiming { + source: "local_claim_scan", + elapsed_ms: started.elapsed().as_millis(), + }); + + let mut model = WorkspacePanelViewModel::empty(workspace_root); + let started = Instant::now(); + let availability = ticket_config_availability(workspace_root); + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_config_probe", + elapsed_ms: started.elapsed().as_millis(), + }); + match availability { + TicketConfigAvailability::Absent => {} + TicketConfigAvailability::Usable => { + model.header.ticket_configured = true; + model.composer = WorkspacePanelComposer::ticket_enabled(); + let started = Instant::now(); + match TicketConfig::load_workspace(workspace_root) { + Ok(config) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_config_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model.header.ticket_root = config.backend_root().to_path_buf(); + let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) + .with_record_language(config.ticket_record_language()); + let started = Instant::now(); + let orchestration_overlay = + load_orchestration_ticket_overlay(workspace_root, &config); + timings.push(WorkspacePanelE2eSourceTiming { + source: "orchestration_overlay_validation_read_git", + elapsed_ms: started.elapsed().as_millis(), + }); + let started = Instant::now(); + match build_ticket_rows( + &backend, + pods, + ®istry, + &orchestration_overlay.states, + ) { + Ok(ticket_rows) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_scan_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model.rows.extend(ticket_rows.rows); + model.header.diagnostics.extend(ticket_rows.diagnostics); + model + .header + .diagnostics + .extend(orchestration_overlay.diagnostics); + } + Err(error) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_scan_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket rows unavailable: {error}" + ))) + } + } + } + Err(error) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_config_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket config is unusable: {error}" + ))) + } + } + } + TicketConfigAvailability::Unusable(message) => { + model.header.ticket_configured = true; + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket config is unusable: {message}" + ))); + } + } + + let started = Instant::now(); + model.rows.extend(pod_rows(pods)); + timings.push(WorkspacePanelE2eSourceTiming { + source: "pod_row_materialization", + elapsed_ms: started.elapsed().as_millis(), + }); + (model, timings) +} + fn build_workspace_panel_with_registry( workspace_root: &Path, pods: &PodList, diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index e551e9f1..f20127d3 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -264,6 +264,14 @@ pub struct RenderedPanelRow { pub title: String, pub status: Option, pub action: Option, + #[serde(default)] + pub disabled_reason: Option, + #[serde(default)] + pub local_state: Option, + #[serde(default)] + pub overlay_state: Option, + #[serde(default)] + pub overlay_detail: Option, pub rect: PanelRect, } @@ -272,6 +280,10 @@ pub struct ExpectedPanelTicketRow { pub id: String, pub title: String, pub status: String, + pub action: Option, + pub disabled_reason: Option, + pub local_state: Option, + pub overlay_state: Option, } impl ExpectedPanelTicketRow { @@ -280,24 +292,221 @@ impl ExpectedPanelTicketRow { id: id.into(), title: title.into(), status: status.into(), + action: None, + disabled_reason: None, + local_state: None, + overlay_state: None, } } + pub fn with_action(mut self, action: impl Into) -> Self { + self.action = Some(action.into()); + self + } + + pub fn with_disabled_reason(mut self, disabled_reason: impl Into) -> Self { + self.disabled_reason = Some(disabled_reason.into()); + self + } + + pub fn with_local_state(mut self, local_state: impl Into) -> Self { + self.local_state = Some(local_state.into()); + self + } + + pub fn with_overlay_state(mut self, overlay_state: impl Into) -> Self { + self.overlay_state = Some(overlay_state.into()); + self + } + pub fn matches(&self, row: &RenderedPanelRow) -> bool { row.key.kind == "ticket" && row.key.id == self.id && row.title == self.title && row.status.as_deref() == Some(self.status.as_str()) + && self.action.as_ref().map_or(true, |action| { + row.action.as_deref() == Some(action.as_str()) + }) + && self.disabled_reason.as_ref().map_or(true, |reason| { + row.disabled_reason + .as_deref() + .is_some_and(|actual| actual.contains(reason)) + }) + && self.local_state.as_ref().map_or(true, |state| { + row.local_state.as_deref() == Some(state.as_str()) + }) + && self.overlay_state.as_ref().map_or(true, |state| { + row.overlay_state.as_deref() == Some(state.as_str()) + }) } fn description(&self) -> String { format!( - "ticket row id={} title={:?} status={}", - self.id, self.title, self.status + "ticket row id={} title={:?} status={} action={:?} disabled_reason={:?} local_state={:?} overlay_state={:?}", + self.id, + self.title, + self.status, + self.action, + self.disabled_reason, + self.local_state, + self.overlay_state, ) } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExpectedDashboardContent { + pub tickets: Vec, + pub pod_names: Vec, + pub companion_status: String, + pub orchestrator_status: String, +} + +impl ExpectedDashboardContent { + pub fn snapshot(&self) -> DashboardContentSnapshot { + DashboardContentSnapshot { + tickets: self.tickets.clone(), + pod_names: self.pod_names.clone(), + companion_status: self.companion_status.clone(), + orchestrator_status: self.orchestrator_status.clone(), + } + } + + fn description(&self) -> String { + let tickets = self + .tickets + .iter() + .map(ExpectedPanelTicketRow::description) + .collect::>() + .join(", "); + let pods = self.pod_names.join(", "); + format!( + "tickets=[{tickets}] pods=[{pods}] companion={} orchestrator={}", + self.companion_status, self.orchestrator_status + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DashboardContentSnapshot { + pub tickets: Vec, + pub pod_names: Vec, + pub companion_status: String, + pub orchestrator_status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardContentReady { + pub snapshot: DashboardSnapshot, + pub categories: DashboardContentCategories, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSnapshot { + pub header: DashboardHeader, + pub rows: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardHeader { + pub ticket_configured: bool, + pub companion: Option, + pub orchestrator: Option, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardCompanionState { + pub pod_name: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardOrchestratorState { + pub pod_name: String, + pub status: String, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardContentCategories { + pub ticket_rows: usize, + pub ready_ticket_rows: usize, + pub planning_ticket_rows: usize, + pub pod_rows: usize, + pub actionable_rows: usize, +} + +impl DashboardContentReady { + pub fn rows_rendered(&self) -> RowsRendered { + RowsRendered { + selected: None, + rows: self.snapshot.rows.clone(), + } + } + + pub fn snapshot_for_expected( + &self, + expected: &ExpectedDashboardContent, + ) -> DashboardContentSnapshot { + DashboardContentSnapshot { + tickets: expected + .tickets + .iter() + .filter(|ticket| self.snapshot.rows.iter().any(|row| ticket.matches(row))) + .cloned() + .collect(), + pod_names: expected + .pod_names + .iter() + .filter(|pod_name| { + self.snapshot + .rows + .iter() + .any(|row| row.key.kind == "pod" && row.key.id == pod_name.as_str()) + }) + .cloned() + .collect(), + companion_status: self + .snapshot + .header + .companion + .as_ref() + .map(|companion| companion.status.clone()) + .unwrap_or_default(), + orchestrator_status: self + .snapshot + .header + .orchestrator + .as_ref() + .map(|orchestrator| orchestrator.status.clone()) + .unwrap_or_default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSourceBreakdown { + pub total_elapsed_ms: u128, + pub sources: Vec, + pub ticket_rows: usize, + pub pod_rows: usize, + pub diagnostics: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSourceTiming { + pub source: String, + pub elapsed_ms: u128, +} + +impl DashboardSourceBreakdown { + pub fn has_source(&self, source: &str) -> bool { + self.sources.iter().any(|timing| timing.source == source) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RowsRendered { pub selected: Option, @@ -542,6 +751,49 @@ impl PanelHarness { serde_json::from_value(event.data).map_err(HarnessError::from) } + /// Waits for the dashboard-content-ready observer event. Unlike first-frame + /// or row-count readiness, this requires representative user-visible content: + /// ready + planning Ticket rows and a Pod row, then checks the fixture-specific + /// rows as a small snapshot of the expected dashboard content. + pub fn wait_for_dashboard_content_ready( + &mut self, + expected: &ExpectedDashboardContent, + timeout: Duration, + ) -> Result { + let expected_snapshot = expected.snapshot(); + let description = expected.description(); + let event = self.wait_for( + format!("dashboard content ready ({description})"), + timeout, + |event| { + if event.event != "dashboard_content_ready" { + return false; + } + serde_json::from_value::(event.data.clone()) + .map(|ready| ready.snapshot_for_expected(expected) == expected_snapshot) + .unwrap_or(false) + }, + )?; + serde_json::from_value(event.data).map_err(HarnessError::from) + } + + pub fn latest_dashboard_source_breakdown( + &mut self, + ) -> Result> { + Ok(self + .events()? + .into_iter() + .rev() + .filter(|event| event.event == "dashboard_source_breakdown") + .find_map(|event| serde_json::from_value(event.data).ok())) + } + + pub fn expect_dashboard_source_breakdown(&mut self) -> Result { + self.latest_dashboard_source_breakdown()?.ok_or_else(|| { + HarnessError::Protocol("missing dashboard_source_breakdown observer event".to_string()) + }) + } + pub fn assert_fixture_ticket_row_not_rendered( &mut self, expected: &ExpectedPanelTicketRow, @@ -1029,6 +1281,7 @@ impl FixtureWorkspace { )?; fixture.ready_ticket_id = first; fixture.planning_ticket_id = second; + fixture.setup_orchestration_overlay(binary)?; fixture.write_fixture_metadata("ready", None)?; Ok(fixture) } @@ -1039,6 +1292,95 @@ impl FixtureWorkspace { READY_FIXTURE_TICKET_TITLE, "ready", ) + .with_action("Queue") + .with_local_state("ready") + } + + pub fn ready_overlay_ticket_row(&self) -> ExpectedPanelTicketRow { + ExpectedPanelTicketRow::new( + self.ready_ticket_id.clone(), + READY_FIXTURE_TICKET_TITLE, + "ready→prog", + ) + .with_action("Wait") + .with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress") + .with_local_state("ready") + .with_overlay_state("inprogress") + } + + pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow { + ExpectedPanelTicketRow::new( + self.planning_ticket_id.clone(), + PLANNING_FIXTURE_TICKET_TITLE, + "planning", + ) + .with_action("Clarify") + .with_disabled_reason("Ticket is still in planning") + .with_local_state("planning") + } + + pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent { + ExpectedDashboardContent { + tickets: vec![ + self.ready_overlay_ticket_row(), + self.planning_fixture_ticket_row(), + ], + pod_names: vec!["workspace".to_string()], + companion_status: "unavailable".to_string(), + orchestrator_status: "unavailable".to_string(), + } + } + + fn setup_orchestration_overlay(&self, binary: &Path) -> Result<()> { + run_git(&self.workspace, &["init"])?; + run_git(&self.workspace, &["checkout", "-B", "develop"])?; + run_git( + &self.workspace, + &["config", "user.email", "fixture@example.invalid"], + )?; + run_git(&self.workspace, &["config", "user.name", "Yoi E2E Fixture"])?; + run_git(&self.workspace, &["add", ".yoi"])?; + run_git(&self.workspace, &["commit", "-m", "fixture tickets"])?; + let orchestration = self.workspace.join(".worktree/orchestration"); + run_git( + &self.workspace, + &[ + "worktree", + "add", + "-b", + "orchestration", + orchestration.to_string_lossy().as_ref(), + "HEAD", + ], + )?; + run_yoi( + binary, + &orchestration, + &self.home, + &self.xdg_data_home, + &self.xdg_state_home, + &self.xdg_config_home, + &self.xdg_runtime_dir, + &self.artifacts_dir, + &["ticket", "state", &self.ready_ticket_id, "queued"], + )?; + run_yoi( + binary, + &orchestration, + &self.home, + &self.xdg_data_home, + &self.xdg_state_home, + &self.xdg_config_home, + &self.xdg_runtime_dir, + &self.artifacts_dir, + &["ticket", "state", &self.ready_ticket_id, "inprogress"], + )?; + run_git(&orchestration, &["add", ".yoi"])?; + run_git( + &orchestration, + &["commit", "-m", "fixture orchestration overlay"], + )?; + Ok(()) } pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { @@ -1379,6 +1721,23 @@ fn create_ticket( .ok_or_else(|| HarnessError::Protocol(format!("could not parse ticket id from {output:?}"))) } +fn run_git(workspace: &Path, args: &[&str]) -> Result<()> { + let output = Command::new("git") + .args(args) + .current_dir(workspace) + .output()?; + if output.status.success() { + return Ok(()); + } + Err(HarnessError::CommandFailed { + program: PathBuf::from("git"), + args: args.iter().map(|arg| (*arg).to_string()).collect(), + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + fn run_yoi( binary: &Path, workspace: &Path, diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 09845e11..c755ef22 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -1,31 +1,109 @@ use std::time::{Duration, Instant}; const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500); -const ROWS_READY_BUDGET: Duration = Duration::from_secs(5); +const DASHBOARD_CONTENT_READY_BUDGET: Duration = Duration::from_secs(5); use yoi_e2e::{ + DashboardCompanionState, DashboardContentCategories, DashboardContentReady, DashboardHeader, + DashboardOrchestratorState, DashboardSnapshot, ExpectedDashboardContent, ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary, }; -#[test] -fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { - let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready"); - let wrong_title = RenderedPanelRow { +fn rendered_ticket_row( + id: &str, + title: &str, + status: &str, + action: Option<&str>, + disabled_reason: Option<&str>, + local_state: Option<&str>, + overlay_state: Option<&str>, +) -> RenderedPanelRow { + RenderedPanelRow { key: PanelRowKey { kind: "ticket".to_string(), - id: "0000000000000".to_string(), + id: id.to_string(), }, - title: "Different Ticket".to_string(), - status: Some("ready".to_string()), - action: None, + title: title.to_string(), + status: Some(status.to_string()), + action: action.map(ToOwned::to_owned), + disabled_reason: disabled_reason.map(ToOwned::to_owned), + local_state: local_state.map(ToOwned::to_owned), + overlay_state: overlay_state.map(ToOwned::to_owned), + overlay_detail: overlay_state.map(|state| format!("orchestration:{state}")), rect: PanelRect { x: 0, y: 0, width: 10, height: 1, }, - }; + } +} + +fn rendered_pod_row(name: &str) -> RenderedPanelRow { + RenderedPanelRow { + key: PanelRowKey { + kind: "pod".to_string(), + id: name.to_string(), + }, + title: name.to_string(), + status: None, + action: None, + disabled_reason: None, + local_state: None, + overlay_state: None, + overlay_detail: None, + rect: PanelRect { + x: 0, + y: 1, + width: 10, + height: 1, + }, + } +} + +fn ready_snapshot(rows: Vec) -> DashboardContentReady { + DashboardContentReady { + snapshot: DashboardSnapshot { + header: DashboardHeader { + ticket_configured: true, + companion: Some(DashboardCompanionState { + pod_name: "workspace".to_string(), + status: "unavailable".to_string(), + }), + orchestrator: Some(DashboardOrchestratorState { + pod_name: "workspace-orchestrator".to_string(), + status: "unavailable".to_string(), + detail: Some("fixture blocks host Pod launch".to_string()), + }), + diagnostics: vec![], + }, + rows, + }, + categories: DashboardContentCategories { + ticket_rows: 2, + ready_ticket_rows: 1, + planning_ticket_rows: 1, + pod_rows: 1, + actionable_rows: 2, + }, + } +} + +#[test] +fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { + let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready") + .with_action("Queue") + .with_local_state("ready"); + let wrong_title = rendered_ticket_row( + "0000000000000", + "Different Ticket", + "ready", + Some("Queue"), + None, + Some("ready"), + None, + ); let wrong_kind = RenderedPanelRow { key: PanelRowKey { kind: "pod".to_string(), @@ -33,7 +111,11 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { }, title: "Ready E2E Ticket".to_string(), status: Some("ready".to_string()), - action: None, + action: Some("Queue".to_string()), + disabled_reason: None, + local_state: Some("ready".to_string()), + overlay_state: None, + overlay_detail: None, rect: PanelRect { x: 0, y: 0, @@ -51,6 +133,91 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { assert!(!rows.has_fixture_ticket_row(&expected)); } +#[test] +fn dashboard_snapshot_rejects_missing_row_wrong_state_missing_overlay_and_missing_action() { + let expected_ready = ExpectedPanelTicketRow::new("ready-id", "Ready E2E Ticket", "ready→prog") + .with_action("Wait") + .with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress") + .with_local_state("ready") + .with_overlay_state("inprogress"); + let expected_planning = + ExpectedPanelTicketRow::new("planning-id", "Planning E2E Ticket", "planning") + .with_action("Clarify") + .with_disabled_reason("Ticket is still in planning") + .with_local_state("planning"); + let expected = ExpectedDashboardContent { + tickets: vec![expected_ready.clone(), expected_planning.clone()], + pod_names: vec!["workspace".to_string()], + companion_status: "unavailable".to_string(), + orchestrator_status: "unavailable".to_string(), + }; + let complete_rows = || { + vec![ + rendered_ticket_row( + "ready-id", + "Ready E2E Ticket", + "ready→prog", + Some("Wait"), + Some("orchestration worktree overlay shows Ticket state inprogress"), + Some("ready"), + Some("inprogress"), + ), + rendered_ticket_row( + "planning-id", + "Planning E2E Ticket", + "planning", + Some("Clarify"), + Some("Ticket is still in planning"), + Some("planning"), + None, + ), + rendered_pod_row("workspace"), + ] + }; + assert_eq!( + ready_snapshot(complete_rows()).snapshot_for_expected(&expected), + expected.snapshot() + ); + + let missing_row = ready_snapshot(vec![ + rendered_ticket_row( + "ready-id", + "Ready E2E Ticket", + "ready→prog", + Some("Wait"), + Some("orchestration worktree overlay shows Ticket state inprogress"), + Some("ready"), + Some("inprogress"), + ), + rendered_pod_row("workspace"), + ]); + assert_ne!( + missing_row.snapshot_for_expected(&expected), + expected.snapshot() + ); + + let mut wrong_state_rows = complete_rows(); + wrong_state_rows[0].status = Some("ready".to_string()); + assert_ne!( + ready_snapshot(wrong_state_rows).snapshot_for_expected(&expected), + expected.snapshot() + ); + + let mut missing_overlay_rows = complete_rows(); + missing_overlay_rows[0].overlay_state = None; + assert_ne!( + ready_snapshot(missing_overlay_rows).snapshot_for_expected(&expected), + expected.snapshot() + ); + + let mut missing_action_rows = complete_rows(); + missing_action_rows[0].action = None; + assert_ne!( + ready_snapshot(missing_action_rows).snapshot_for_expected(&expected), + expected.snapshot() + ); +} + #[test] fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; @@ -114,11 +281,11 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res } #[test] -fn panel_fixture_ticket_row_ready_has_startup_budget() -> yoi_e2e::Result<()> { +fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; assert_fixture_paths_are_isolated(&fixture); - let ready_ticket = fixture.ready_fixture_ticket_row(); + let expected_content = fixture.expected_dashboard_content(); let started = Instant::now(); let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; @@ -137,23 +304,63 @@ fn panel_fixture_ticket_row_ready_has_startup_budget() -> yoi_e2e::Result<()> { panel.artifacts().dir.display() ); - let rows_ready_remaining = ROWS_READY_BUDGET + let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - let rows = panel.wait_for_fixture_ticket_rows_ready(&ready_ticket, rows_ready_remaining)?; + let content_ready = + panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?; assert!( - rows.has_fixture_ticket_row(&ready_ticket), - "rows-ready event must contain concrete ready fixture Ticket row; artifacts at {}", + content_ready.snapshot.header.ticket_configured, + "dashboard content ready must include usable Ticket configuration; artifacts at {}", panel.artifacts().dir.display() ); - let rows_ready_elapsed = started.elapsed(); + assert!( + content_ready.snapshot.header.companion.is_some() + && content_ready.snapshot.header.orchestrator.is_some(), + "dashboard content ready must include Companion and Orchestrator header status; got {:?}; artifacts at {}", + content_ready.snapshot.header, + panel.artifacts().dir.display() + ); + assert_eq!( + content_ready.snapshot_for_expected(&expected_content), + expected_content.snapshot(), + "dashboard content ready must match expected Ticket/action/overlay/header snapshot; artifacts at {}", + panel.artifacts().dir.display() + ); + assert!( + content_ready.categories.ready_ticket_rows > 0 + && content_ready.categories.planning_ticket_rows > 0 + && content_ready.categories.pod_rows > 0, + "dashboard content ready must include ready Ticket, planning Ticket, and Pod categories; got {:?}; artifacts at {}", + content_ready.categories, + panel.artifacts().dir.display() + ); + let content_ready_elapsed = started.elapsed(); eprintln!( - "panel fixture rows ready: {rows_ready_elapsed:?} (budget {ROWS_READY_BUDGET:?}); artifacts at {}", + "panel dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( - rows_ready_elapsed <= ROWS_READY_BUDGET, - "fixture rows ready took {rows_ready_elapsed:?}, budget {ROWS_READY_BUDGET:?}; artifacts at {}", + content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET, + "dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}", + panel.artifacts().dir.display() + ); + + let source_breakdown = panel.expect_dashboard_source_breakdown()?; + assert!( + source_breakdown.has_source("pod_metadata_status_probe.initial") + && source_breakdown.has_source("ticket_config_probe") + && source_breakdown.has_source("local_claim_scan") + && source_breakdown.has_source("ticket_scan_parse") + && source_breakdown.has_source("orchestration_overlay_validation_read_git") + && source_breakdown.has_source("workspace_panel.build.total"), + "dashboard source breakdown should include pod metadata/status, ticket scan/parse, overlay validation/read/git, local claim scan, and panel-build sources; got {:?}; artifacts at {}", + source_breakdown, + panel.artifacts().dir.display() + ); + eprintln!( + "panel dashboard source breakdown: {:?}; artifacts at {}", + source_breakdown, panel.artifacts().dir.display() );