merge: sync orchestration before queue 00001KVDH2E06
This commit is contained in:
commit
667873bdbf
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Plugin: enforce Plugin permission grants'
|
title: 'Plugin: enforce Plugin permission grants'
|
||||||
state: 'inprogress'
|
state: 'closed'
|
||||||
created_at: '2026-06-15T14:48:59Z'
|
created_at: '2026-06-15T14:48:59Z'
|
||||||
updated_at: '2026-06-18T13:12:47Z'
|
updated_at: '2026-06-18T14:24:42Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['plugin', 'permission', 'grant-enforcement', 'capability-boundary', 'tool-execution']
|
risk_flags: ['plugin', 'permission', 'grant-enforcement', 'capability-boundary', 'tool-execution']
|
||||||
|
|
|
||||||
30
.yoi/tickets/00001KV5W3PJ3/resolution.md
Normal file
30
.yoi/tickets/00001KV5W3PJ3/resolution.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -122,3 +122,268 @@ State handling:
|
||||||
- Ticket remains `inprogress` because acceptance and worktree side effects already happened; it is not silently returned to `queued`.
|
- Ticket remains `inprogress` because acceptance and worktree side effects already happened; it is not silently returned to `queued`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T13:56:44Z -->
|
||||||
|
|
||||||
|
## 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 上で作業するよう指示済み。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:15:12Z -->
|
||||||
|
|
||||||
|
## 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 との整合性を確認する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: yoi-orchestrator at: 2026-06-18T14:16:18Z -->
|
||||||
|
|
||||||
|
## 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 に修正依頼する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: yoi-reviewer-00001KV5W3PJ3 at: 2026-06-18T14:22:32Z status: approve -->
|
||||||
|
|
||||||
|
## 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 として許容可能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:23:59Z -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:24:23Z -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T14:24:29Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-06-18T14:24:42Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-06-18T14:24:42Z status: 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Panel startup latency をユーザー目線の dashboard content ready 基準で計測・改善する'
|
title: 'Panel startup latency をユーザー目線の dashboard content ready 基準で計測・改善する'
|
||||||
state: 'queued'
|
state: 'inprogress'
|
||||||
created_at: '2026-06-18T13:30:51Z'
|
created_at: '2026-06-18T13:30:51Z'
|
||||||
updated_at: '2026-06-18T13:55:08Z'
|
updated_at: '2026-06-18T14:46:51Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['panel', 'e2e', 'startup-latency', 'user-visible-readiness', 'dashboard-content', 'profiling']
|
risk_flags: ['panel', 'e2e', 'startup-latency', 'user-visible-readiness', 'dashboard-content', 'profiling']
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,415 @@ LocalTicketBackend によって作成されました。
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: yoi-orchestrator at: 2026-06-18T13:58:25Z -->
|
||||||
|
|
||||||
|
## 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 で起動する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T13:58:33Z from: queued to: inprogress reason: orchestrator_acceptance_dashboard_content_ready field: state -->
|
||||||
|
|
||||||
|
## 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 を記録する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T13:59:23Z -->
|
||||||
|
|
||||||
|
## 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 と実装報告を返す予定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:15:27Z -->
|
||||||
|
|
||||||
|
## 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 に変更していないことを確認する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: yoi-orchestrator at: 2026-06-18T14:16:26Z -->
|
||||||
|
|
||||||
|
## 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 に修正依頼する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: yoi-reviewer-00001KVDETSN6 at: 2026-06-18T14:20:21Z status: request_changes -->
|
||||||
|
|
||||||
|
## 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 を記録する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: yoi-orchestrator at: 2026-06-18T14:20:54Z -->
|
||||||
|
|
||||||
|
## 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 で再レビューする。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:41:45Z -->
|
||||||
|
|
||||||
|
## 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 で再レビューする。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: plan author: yoi-orchestrator at: 2026-06-18T14:42:24Z -->
|
||||||
|
|
||||||
|
## 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 に戻す。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: yoi-reviewer-00001KVDETSN6-r2 at: 2026-06-18T14:46:51Z status: approve -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -66,18 +66,111 @@ impl PluginExactVersion {
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct PluginGrantConfig {
|
pub struct PluginGrantConfig {
|
||||||
pub tools: Vec<String>,
|
/// Source-qualified package id this grant is pinned to, for example `project:example`.
|
||||||
pub secrets: Vec<String>,
|
pub id: Option<String>,
|
||||||
pub filesystem: Vec<String>,
|
/// Exact package version this grant is pinned to.
|
||||||
pub network: bool,
|
pub version: Option<PluginExactVersion>,
|
||||||
|
/// Deterministic package digest this grant is pinned to.
|
||||||
|
pub digest: Option<String>,
|
||||||
|
/// Explicit capabilities granted for the pinned package identity/version/digest.
|
||||||
|
pub permissions: Vec<PluginPermission>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginGrantConfig {
|
impl PluginGrantConfig {
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.tools.is_empty()
|
self.permissions.is_empty()
|
||||||
&& self.secrets.is_empty()
|
}
|
||||||
&& self.filesystem.is_empty()
|
|
||||||
&& !self.network
|
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<String>) -> Self {
|
||||||
|
Self::Tool { name: name.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_namespace(namespace: impl Into<String>) -> 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<PluginHookManifest>,
|
pub hooks: Vec<PluginHookManifest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tools: Vec<PluginToolManifest>,
|
pub tools: Vec<PluginToolManifest>,
|
||||||
|
/// 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<PluginPermission>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginPackageManifest {
|
impl PluginPackageManifest {
|
||||||
|
|
@ -229,6 +326,11 @@ pub struct PluginToolManifest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub input_schema: serde_json::Value,
|
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)]
|
#[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(
|
resolution.diagnostics.push(
|
||||||
PluginDiagnostic::new(
|
PluginDiagnostic::new(
|
||||||
PluginDiagnosticKind::Grant,
|
PluginDiagnosticKind::Grant,
|
||||||
PluginDiagnosticPhase::Resolution,
|
PluginDiagnosticPhase::Resolution,
|
||||||
"plugin authority grants are not implemented and fail closed",
|
message,
|
||||||
)
|
)
|
||||||
.with_source(identity.source)
|
.with_source(identity.source)
|
||||||
.with_identity(&identity)
|
.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::<PluginPackageManifest>(
|
||||||
|
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]
|
#[test]
|
||||||
fn surface_and_grant_failures_do_not_resolve() {
|
fn surface_and_grant_failures_do_not_resolve() {
|
||||||
let (report, _) = fixture_with_enabled_plugin(false);
|
let (report, _) = fixture_with_enabled_plugin(false);
|
||||||
|
|
@ -1951,7 +2144,7 @@ input_schema = { type = "object", properties = { query = { type = "string" } },
|
||||||
PluginEnablementConfig {
|
PluginEnablementConfig {
|
||||||
id: "project:example".to_string(),
|
id: "project:example".to_string(),
|
||||||
grants: PluginGrantConfig {
|
grants: PluginGrantConfig {
|
||||||
filesystem: vec![".".to_string()],
|
permissions: vec![PluginPermission::surface(PluginSurface::Tool)],
|
||||||
..PluginGrantConfig::default()
|
..PluginGrantConfig::default()
|
||||||
},
|
},
|
||||||
..PluginEnablementConfig::default()
|
..PluginEnablementConfig::default()
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ use llm_worker::tool::{
|
||||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
|
||||||
};
|
};
|
||||||
use manifest::plugin::{
|
use manifest::plugin::{
|
||||||
PluginConfig, PluginDiscoveryLimits, PluginSurface, ResolvedPluginRecord,
|
PluginConfig, PluginDiscoveryLimits, PluginHostApi, PluginPermission, PluginSurface,
|
||||||
read_resolved_plugin_runtime_module,
|
PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_module,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|
@ -106,6 +106,8 @@ impl FeatureModule for PluginToolFeature {
|
||||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
validate_declared_tool_names(&self.record)?;
|
validate_declared_tool_names(&self.record)?;
|
||||||
let origin = self.origin();
|
let origin = self.origin();
|
||||||
|
let mut registered = 0usize;
|
||||||
|
let mut denied = Vec::new();
|
||||||
for tool in &self.record.manifest.tools {
|
for tool in &self.record.manifest.tools {
|
||||||
validate_tool_name(&tool.name).map_err(|reason| {
|
validate_tool_name(&tool.name).map_err(|reason| {
|
||||||
FeatureInstallError::Install(format!(
|
FeatureInstallError::Install(format!(
|
||||||
|
|
@ -119,6 +121,17 @@ impl FeatureModule for PluginToolFeature {
|
||||||
self.record.identity, tool.name
|
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(
|
context.tools().register(ToolContribution::new(
|
||||||
tool.name.clone(),
|
tool.name.clone(),
|
||||||
plugin_wasm_tool_definition(
|
plugin_wasm_tool_definition(
|
||||||
|
|
@ -129,11 +142,128 @@ impl FeatureModule for PluginToolFeature {
|
||||||
origin.clone(),
|
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(())
|
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_HOST_MODULE: &str = "yoi:tool";
|
||||||
const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call";
|
const PLUGIN_WASM_ENTRYPOINT: &str = "yoi_tool_call";
|
||||||
const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024;
|
const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024;
|
||||||
|
|
@ -259,6 +389,20 @@ fn run_plugin_wasm_tool(
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
input: Vec<u8>,
|
input: Vec<u8>,
|
||||||
) -> Result<ToolOutput, PluginWasmError> {
|
) -> Result<ToolOutput, PluginWasmError> {
|
||||||
|
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 limits = PluginDiscoveryLimits::default();
|
||||||
let module_bytes = read_resolved_plugin_runtime_module(&record, &limits)
|
let module_bytes = read_resolved_plugin_runtime_module(&record, &limits)
|
||||||
.map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?;
|
.map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?;
|
||||||
|
|
@ -276,7 +420,7 @@ fn run_plugin_wasm_tool(
|
||||||
let engine = wasmi::Engine::new(&config);
|
let engine = wasmi::Engine::new(&config);
|
||||||
let module = wasmi::Module::new(&engine, &module_bytes[..])
|
let module = wasmi::Module::new(&engine, &module_bytes[..])
|
||||||
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
.map_err(|error| PluginWasmError::Module(error.to_string()))?;
|
||||||
validate_wasm_imports(&module)?;
|
validate_wasm_imports(&record, &module)?;
|
||||||
|
|
||||||
let store_limits = wasmi::StoreLimitsBuilder::new()
|
let store_limits = wasmi::StoreLimitsBuilder::new()
|
||||||
.memory_size(PLUGIN_WASM_MEMORY_BYTES)
|
.memory_size(PLUGIN_WASM_MEMORY_BYTES)
|
||||||
|
|
@ -319,8 +463,27 @@ fn run_plugin_wasm_tool(
|
||||||
decode_plugin_wasm_output(&store.data().output)
|
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() {
|
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 {
|
if import.module() != PLUGIN_WASM_HOST_MODULE {
|
||||||
return Err(PluginWasmError::Module(format!(
|
return Err(PluginWasmError::Module(format!(
|
||||||
"unsupported import module `{}`; only `{}` is available",
|
"unsupported import module `{}`; only `{}` is available",
|
||||||
|
|
@ -752,8 +915,9 @@ fn is_supported_schema_keyword(key: &str) -> bool {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use manifest::plugin::{
|
use manifest::plugin::{
|
||||||
PluginDiscoveryOptions, PluginEnablementConfig, PluginPackageManifest,
|
PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig,
|
||||||
PluginRuntimeManifest, SourceQualifiedPluginId, resolve_plugin_config_for_startup,
|
PluginPackageManifest, PluginRuntimeManifest, SourceQualifiedPluginId,
|
||||||
|
resolve_plugin_config_for_startup,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
@ -765,6 +929,7 @@ mod tests {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: format!("{name} tool"),
|
description: format!("{name} tool"),
|
||||||
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
|
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
|
||||||
|
external_write: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -777,6 +942,7 @@ mod tests {
|
||||||
tools: Vec<manifest::plugin::PluginToolManifest>,
|
tools: Vec<manifest::plugin::PluginToolManifest>,
|
||||||
) -> ResolvedPluginRecord {
|
) -> ResolvedPluginRecord {
|
||||||
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
|
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
|
||||||
|
let permissions = tool_permissions(&tools);
|
||||||
ResolvedPluginRecord {
|
ResolvedPluginRecord {
|
||||||
identity: parsed_identity.clone(),
|
identity: parsed_identity.clone(),
|
||||||
source: parsed_identity.source,
|
source: parsed_identity.source,
|
||||||
|
|
@ -794,13 +960,29 @@ mod tests {
|
||||||
runtime: None,
|
runtime: None,
|
||||||
hooks: Vec::new(),
|
hooks: Vec::new(),
|
||||||
tools,
|
tools,
|
||||||
|
permissions: permissions.clone(),
|
||||||
},
|
},
|
||||||
enabled_surfaces: vec![PluginSurface::Tool],
|
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,
|
config: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec<PluginPermission> {
|
||||||
|
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 {
|
fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize {
|
||||||
report
|
report
|
||||||
.reports
|
.reports
|
||||||
|
|
@ -818,6 +1000,20 @@ mod tests {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn install_plugin_record(
|
||||||
|
record: ResolvedPluginRecord,
|
||||||
|
) -> (
|
||||||
|
super::super::FeatureRegistryInstallReport,
|
||||||
|
Vec<ToolDefinition>,
|
||||||
|
) {
|
||||||
|
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]
|
#[test]
|
||||||
fn rejects_invalid_root_schema() {
|
fn rejects_invalid_root_schema() {
|
||||||
let schema = json!({"type":"string"});
|
let schema = json!({"type":"string"});
|
||||||
|
|
@ -942,6 +1138,146 @@ mod tests {
|
||||||
assert_eq!(origin.surface, "tool");
|
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]
|
#[test]
|
||||||
fn package_without_enabled_tool_surface_registers_no_schema() {
|
fn package_without_enabled_tool_surface_registers_no_schema() {
|
||||||
let mut config = PluginConfig::default();
|
let mut config = PluginConfig::default();
|
||||||
|
|
@ -1176,7 +1512,14 @@ mod tests {
|
||||||
resolved.diagnostics
|
resolved.diagnostics
|
||||||
);
|
);
|
||||||
assert_eq!(resolved.resolved.len(), 1);
|
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]) {
|
fn write_plugin_package(path: &Path, wasm: &[u8]) {
|
||||||
|
|
@ -1192,6 +1535,14 @@ kind = "wasm"
|
||||||
entry = "plugin.wasm"
|
entry = "plugin.wasm"
|
||||||
abi = "yoi-plugin-wasm-1"
|
abi = "yoi-plugin-wasm-1"
|
||||||
|
|
||||||
|
[[permissions]]
|
||||||
|
kind = "surface"
|
||||||
|
surface = "tool"
|
||||||
|
|
||||||
|
[[permissions]]
|
||||||
|
kind = "tool"
|
||||||
|
name = "PluginEcho"
|
||||||
|
|
||||||
[[tools]]
|
[[tools]]
|
||||||
name = "PluginEcho"
|
name = "PluginEcho"
|
||||||
description = "Echo plugin tool"
|
description = "Echo plugin tool"
|
||||||
|
|
@ -1296,6 +1647,17 @@ input_schema = { type = "object", additionalProperties = true }
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn https_import_module() -> Vec<u8> {
|
||||||
|
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 {
|
fn wat_bytes(bytes: &[u8]) -> String {
|
||||||
bytes
|
bytes
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -5354,6 +5354,7 @@ permission = "read"
|
||||||
runtime: None,
|
runtime: None,
|
||||||
hooks: vec![],
|
hooks: vec![],
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
|
permissions: vec![],
|
||||||
},
|
},
|
||||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||||
grants: manifest::plugin::PluginGrantConfig::default(),
|
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,19 @@ use crate::pod_list::{
|
||||||
use crate::role_session_registry::{
|
use crate::role_session_registry::{
|
||||||
PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult,
|
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::{
|
use crate::workspace_panel::{
|
||||||
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
|
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
|
||||||
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
||||||
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
||||||
PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus,
|
PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus,
|
||||||
WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row,
|
WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row,
|
||||||
build_workspace_panel, companion_pod_presence, decide_companion_lifecycle,
|
companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle,
|
||||||
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
|
local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability,
|
||||||
ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name,
|
workspace_companion_pod_name, workspace_orchestrator_pod_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ENTRIES: usize = 50;
|
const MAX_ENTRIES: usize = 50;
|
||||||
|
|
@ -925,14 +929,14 @@ impl PanelRowHitBox {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PanelE2eRowKey {
|
struct PanelE2eRowKey {
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PanelE2eRect {
|
struct PanelE2eRect {
|
||||||
x: u16,
|
x: u16,
|
||||||
y: u16,
|
y: u16,
|
||||||
|
|
@ -941,22 +945,92 @@ struct PanelE2eRect {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PanelE2eRenderedRow {
|
struct PanelE2eRenderedRow {
|
||||||
key: PanelE2eRowKey,
|
key: PanelE2eRowKey,
|
||||||
title: String,
|
title: String,
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
action: Option<&'static str>,
|
action: Option<&'static str>,
|
||||||
|
disabled_reason: Option<String>,
|
||||||
|
local_state: Option<String>,
|
||||||
|
overlay_state: Option<String>,
|
||||||
|
overlay_detail: Option<String>,
|
||||||
rect: PanelE2eRect,
|
rect: PanelE2eRect,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PanelE2eRowsRendered {
|
struct PanelE2eRowsRendered {
|
||||||
selected: Option<PanelE2eRowKey>,
|
selected: Option<PanelE2eRowKey>,
|
||||||
|
header: PanelE2eDashboardHeader,
|
||||||
rows: Vec<PanelE2eRenderedRow>,
|
rows: Vec<PanelE2eRenderedRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct PanelE2eDashboardHeader {
|
||||||
|
ticket_configured: bool,
|
||||||
|
companion: Option<PanelE2eCompanionState>,
|
||||||
|
orchestrator: Option<PanelE2eOrchestratorState>,
|
||||||
|
diagnostics: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<PanelE2eRenderedRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<PanelE2eSourceTiming>,
|
||||||
|
ticket_rows: usize,
|
||||||
|
pod_rows: usize,
|
||||||
|
diagnostics: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
|
fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
|
||||||
match key {
|
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) struct MultiPodApp {
|
||||||
pub(crate) list: PodList,
|
pub(crate) list: PodList,
|
||||||
pub(crate) panel: WorkspacePanelViewModel,
|
pub(crate) panel: WorkspacePanelViewModel,
|
||||||
|
|
@ -1010,6 +1154,8 @@ pub(crate) struct MultiPodApp {
|
||||||
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
|
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
|
||||||
orchestrator_work_set: OrchestratorWorkSet,
|
orchestrator_work_set: OrchestratorWorkSet,
|
||||||
orchestrator_queue_attention: Option<OrchestratorQueueAttentionFreshness>,
|
orchestrator_queue_attention: Option<OrchestratorQueueAttentionFreshness>,
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
emitted_dashboard_content_ready: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiPodApp {
|
impl MultiPodApp {
|
||||||
|
|
@ -1046,6 +1192,8 @@ impl MultiPodApp {
|
||||||
last_orchestrator_lifecycle_failure: None,
|
last_orchestrator_lifecycle_failure: None,
|
||||||
orchestrator_work_set: OrchestratorWorkSet::default(),
|
orchestrator_work_set: OrchestratorWorkSet::default(),
|
||||||
orchestrator_queue_attention: None,
|
orchestrator_queue_attention: None,
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
emitted_dashboard_content_ready: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1355,25 +1503,52 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
fn emit_rows_rendered(&self) {
|
fn emit_rows_rendered(&mut self) {
|
||||||
let rows = self
|
let rows: Vec<_> = self
|
||||||
.row_hit_boxes
|
.row_hit_boxes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|hit| {
|
.map(|hit| {
|
||||||
let panel_row = self.panel.row(&hit.key);
|
let panel_row = self.panel.row(&hit.key);
|
||||||
let (title, status, action) = match panel_row {
|
let (
|
||||||
Some(row) => (
|
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(),
|
row.title.clone(),
|
||||||
Some(row.status.clone()),
|
Some(row.status.clone()),
|
||||||
row.next_action.map(NextUserAction::label),
|
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 {
|
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) => {
|
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => {
|
||||||
(id.clone(), None, None)
|
(id.clone(), None, None, None, None, None, None)
|
||||||
}
|
}
|
||||||
PanelRowKey::TicketIntakePod { pod_name, .. } => {
|
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,
|
title,
|
||||||
status,
|
status,
|
||||||
action,
|
action,
|
||||||
|
disabled_reason,
|
||||||
|
local_state,
|
||||||
|
overlay_state,
|
||||||
|
overlay_detail,
|
||||||
rect: panel_e2e_rect(hit.rect),
|
rect: panel_e2e_rect(hit.rect),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.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(
|
crate::e2e_observer::emit(
|
||||||
"panel",
|
"panel",
|
||||||
"rows_rendered",
|
"rows_rendered",
|
||||||
PanelE2eRowsRendered {
|
PanelE2eRowsRendered {
|
||||||
selected: self.selected_row.as_ref().map(panel_e2e_row_key),
|
selected: selected.clone(),
|
||||||
rows,
|
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) {
|
fn ensure_selection_visible(&mut self) {
|
||||||
|
|
@ -2286,12 +2483,35 @@ async fn load_multi_pod_snapshot(
|
||||||
lifecycle_mode: OrchestratorLifecycleMode,
|
lifecycle_mode: OrchestratorLifecycleMode,
|
||||||
) -> Result<MultiPodSnapshot, MultiPodError> {
|
) -> Result<MultiPodSnapshot, MultiPodError> {
|
||||||
let workspace_root = current_workspace_root();
|
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 companion_pod_name = workspace_companion_pod_name(&workspace_root);
|
||||||
let list_selected_name = selected_name
|
let list_selected_name = selected_name
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| Some(companion_pod_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?;
|
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?;
|
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() {
|
let companion = match lifecycle_mode.clone() {
|
||||||
OrchestratorLifecycleMode::Ensure { runtime_command } => {
|
OrchestratorLifecycleMode::Ensure { runtime_command } => {
|
||||||
ensure_workspace_companion(
|
ensure_workspace_companion(
|
||||||
|
|
@ -2306,17 +2526,48 @@ async fn load_multi_pod_snapshot(
|
||||||
observe_workspace_companion(companion_pod_name, companion_presence)
|
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 {
|
if companion.reload_pods {
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
let source_started = Instant::now();
|
||||||
list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
|
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);
|
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);
|
let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root);
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
let source_started = Instant::now();
|
||||||
let orchestrator_presence = match &config {
|
let orchestrator_presence = match &config {
|
||||||
TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None,
|
TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None,
|
||||||
TicketConfigAvailability::Usable => {
|
TicketConfigAvailability::Usable => {
|
||||||
Some(load_exact_pod_presence(&orchestrator_pod_name).await?)
|
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 {
|
let orchestrator = match lifecycle_mode {
|
||||||
OrchestratorLifecycleMode::Ensure { runtime_command } => {
|
OrchestratorLifecycleMode::Ensure { runtime_command } => {
|
||||||
ensure_workspace_orchestrator(
|
ensure_workspace_orchestrator(
|
||||||
|
|
@ -2332,14 +2583,63 @@ async fn load_multi_pod_snapshot(
|
||||||
observe_workspace_orchestrator(config, orchestrator_pod_name, orchestrator_presence)
|
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 {
|
if orchestrator.reload_pods {
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
let source_started = Instant::now();
|
||||||
list = load_pod_list(list_selected_name, MAX_ENTRIES).await?;
|
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);
|
let mut panel = build_workspace_panel(&workspace_root, &list);
|
||||||
panel.header.companion = companion.state;
|
panel.header.companion = companion.state;
|
||||||
panel.header.diagnostics.extend(companion.diagnostics);
|
panel.header.diagnostics.extend(companion.diagnostics);
|
||||||
panel.header.orchestrator = orchestrator.state;
|
panel.header.orchestrator = orchestrator.state;
|
||||||
panel.header.diagnostics.extend(orchestrator.diagnostics);
|
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 })
|
Ok(MultiPodSnapshot { list, panel })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use protocol::PodStatus;
|
use protocol::PodStatus;
|
||||||
use ticket::config::{
|
use ticket::config::{
|
||||||
|
|
@ -731,6 +733,7 @@ fn git_output(worktree_root: &Path, args: &[&str]) -> Result<String, String> {
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "e2e-test", allow(dead_code))]
|
||||||
pub(crate) fn build_workspace_panel(
|
pub(crate) fn build_workspace_panel(
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
|
|
@ -758,6 +761,148 @@ pub(crate) fn build_workspace_panel(
|
||||||
build_workspace_panel_with_registry(workspace_root, pods, ®istry)
|
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<WorkspacePanelE2eSourceTiming>) {
|
||||||
|
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(
|
fn build_workspace_panel_with_registry(
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,14 @@ pub struct RenderedPanelRow {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
pub action: Option<String>,
|
pub action: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_state: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overlay_state: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub overlay_detail: Option<String>,
|
||||||
pub rect: PanelRect,
|
pub rect: PanelRect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +280,10 @@ pub struct ExpectedPanelTicketRow {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
pub action: Option<String>,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
pub local_state: Option<String>,
|
||||||
|
pub overlay_state: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExpectedPanelTicketRow {
|
impl ExpectedPanelTicketRow {
|
||||||
|
|
@ -280,24 +292,221 @@ impl ExpectedPanelTicketRow {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
title: title.into(),
|
title: title.into(),
|
||||||
status: status.into(),
|
status: status.into(),
|
||||||
|
action: None,
|
||||||
|
disabled_reason: None,
|
||||||
|
local_state: None,
|
||||||
|
overlay_state: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_action(mut self, action: impl Into<String>) -> Self {
|
||||||
|
self.action = Some(action.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_disabled_reason(mut self, disabled_reason: impl Into<String>) -> Self {
|
||||||
|
self.disabled_reason = Some(disabled_reason.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_local_state(mut self, local_state: impl Into<String>) -> Self {
|
||||||
|
self.local_state = Some(local_state.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_overlay_state(mut self, overlay_state: impl Into<String>) -> Self {
|
||||||
|
self.overlay_state = Some(overlay_state.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn matches(&self, row: &RenderedPanelRow) -> bool {
|
pub fn matches(&self, row: &RenderedPanelRow) -> bool {
|
||||||
row.key.kind == "ticket"
|
row.key.kind == "ticket"
|
||||||
&& row.key.id == self.id
|
&& row.key.id == self.id
|
||||||
&& row.title == self.title
|
&& row.title == self.title
|
||||||
&& row.status.as_deref() == Some(self.status.as_str())
|
&& 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 {
|
fn description(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"ticket row id={} title={:?} status={}",
|
"ticket row id={} title={:?} status={} action={:?} disabled_reason={:?} local_state={:?} overlay_state={:?}",
|
||||||
self.id, self.title, self.status
|
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<ExpectedPanelTicketRow>,
|
||||||
|
pub pod_names: Vec<String>,
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<ExpectedPanelTicketRow>,
|
||||||
|
pub pod_names: Vec<String>,
|
||||||
|
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<RenderedPanelRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DashboardHeader {
|
||||||
|
pub ticket_configured: bool,
|
||||||
|
pub companion: Option<DashboardCompanionState>,
|
||||||
|
pub orchestrator: Option<DashboardOrchestratorState>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub diagnostics: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<DashboardSourceTiming>,
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RowsRendered {
|
pub struct RowsRendered {
|
||||||
pub selected: Option<PanelRowKey>,
|
pub selected: Option<PanelRowKey>,
|
||||||
|
|
@ -542,6 +751,49 @@ impl PanelHarness {
|
||||||
serde_json::from_value(event.data).map_err(HarnessError::from)
|
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<DashboardContentReady> {
|
||||||
|
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::<DashboardContentReady>(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<Option<DashboardSourceBreakdown>> {
|
||||||
|
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<DashboardSourceBreakdown> {
|
||||||
|
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(
|
pub fn assert_fixture_ticket_row_not_rendered(
|
||||||
&mut self,
|
&mut self,
|
||||||
expected: &ExpectedPanelTicketRow,
|
expected: &ExpectedPanelTicketRow,
|
||||||
|
|
@ -1029,6 +1281,7 @@ impl FixtureWorkspace {
|
||||||
)?;
|
)?;
|
||||||
fixture.ready_ticket_id = first;
|
fixture.ready_ticket_id = first;
|
||||||
fixture.planning_ticket_id = second;
|
fixture.planning_ticket_id = second;
|
||||||
|
fixture.setup_orchestration_overlay(binary)?;
|
||||||
fixture.write_fixture_metadata("ready", None)?;
|
fixture.write_fixture_metadata("ready", None)?;
|
||||||
Ok(fixture)
|
Ok(fixture)
|
||||||
}
|
}
|
||||||
|
|
@ -1039,6 +1292,95 @@ impl FixtureWorkspace {
|
||||||
READY_FIXTURE_TICKET_TITLE,
|
READY_FIXTURE_TICKET_TITLE,
|
||||||
"ready",
|
"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 {
|
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:?}")))
|
.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(
|
fn run_yoi(
|
||||||
binary: &Path,
|
binary: &Path,
|
||||||
workspace: &Path,
|
workspace: &Path,
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,109 @@
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500);
|
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::{
|
use yoi_e2e::{
|
||||||
|
DashboardCompanionState, DashboardContentCategories, DashboardContentReady, DashboardHeader,
|
||||||
|
DashboardOrchestratorState, DashboardSnapshot, ExpectedDashboardContent,
|
||||||
ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness,
|
ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness,
|
||||||
PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary,
|
PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
fn rendered_ticket_row(
|
||||||
fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() {
|
id: &str,
|
||||||
let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready");
|
title: &str,
|
||||||
let wrong_title = RenderedPanelRow {
|
status: &str,
|
||||||
|
action: Option<&str>,
|
||||||
|
disabled_reason: Option<&str>,
|
||||||
|
local_state: Option<&str>,
|
||||||
|
overlay_state: Option<&str>,
|
||||||
|
) -> RenderedPanelRow {
|
||||||
|
RenderedPanelRow {
|
||||||
key: PanelRowKey {
|
key: PanelRowKey {
|
||||||
kind: "ticket".to_string(),
|
kind: "ticket".to_string(),
|
||||||
id: "0000000000000".to_string(),
|
id: id.to_string(),
|
||||||
},
|
},
|
||||||
title: "Different Ticket".to_string(),
|
title: title.to_string(),
|
||||||
status: Some("ready".to_string()),
|
status: Some(status.to_string()),
|
||||||
action: None,
|
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 {
|
rect: PanelRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 1,
|
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<RenderedPanelRow>) -> 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 {
|
let wrong_kind = RenderedPanelRow {
|
||||||
key: PanelRowKey {
|
key: PanelRowKey {
|
||||||
kind: "pod".to_string(),
|
kind: "pod".to_string(),
|
||||||
|
|
@ -33,7 +111,11 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() {
|
||||||
},
|
},
|
||||||
title: "Ready E2E Ticket".to_string(),
|
title: "Ready E2E Ticket".to_string(),
|
||||||
status: Some("ready".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 {
|
rect: PanelRect {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|
@ -51,6 +133,91 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() {
|
||||||
assert!(!rows.has_fixture_ticket_row(&expected));
|
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]
|
#[test]
|
||||||
fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> {
|
fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> {
|
||||||
let binary = yoi_binary()?;
|
let binary = yoi_binary()?;
|
||||||
|
|
@ -114,11 +281,11 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 binary = yoi_binary()?;
|
||||||
let fixture = FixtureWorkspace::new(&binary)?;
|
let fixture = FixtureWorkspace::new(&binary)?;
|
||||||
assert_fixture_paths_are_isolated(&fixture);
|
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 started = Instant::now();
|
||||||
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
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()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows_ready_remaining = ROWS_READY_BUDGET
|
let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET
|
||||||
.checked_sub(started.elapsed())
|
.checked_sub(started.elapsed())
|
||||||
.unwrap_or_else(|| Duration::from_millis(0));
|
.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!(
|
assert!(
|
||||||
rows.has_fixture_ticket_row(&ready_ticket),
|
content_ready.snapshot.header.ticket_configured,
|
||||||
"rows-ready event must contain concrete ready fixture Ticket row; artifacts at {}",
|
"dashboard content ready must include usable Ticket configuration; artifacts at {}",
|
||||||
panel.artifacts().dir.display()
|
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!(
|
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()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rows_ready_elapsed <= ROWS_READY_BUDGET,
|
content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET,
|
||||||
"fixture rows ready took {rows_ready_elapsed:?}, budget {ROWS_READY_BUDGET:?}; artifacts at {}",
|
"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()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user