Compare commits

...

53 Commits

Author SHA1 Message Date
7593202492
test: align panel dashboard readiness fixture 2026-06-19 00:53:30 +09:00
63449a8c26
ticket: close panel workspace pod filter 2026-06-19 00:40:49 +09:00
0ef36b4e02
merge: panel workspace pod filter 2026-06-19 00:38:03 +09:00
8e8d95eba4
ticket: approve panel workspace pod filter 2026-06-19 00:38:03 +09:00
49cc0f2e80
ticket: dispatch panel workspace pod re-review 2026-06-19 00:29:55 +09:00
f7179f7a99
ticket: record panel workspace pod fix report 2026-06-19 00:29:15 +09:00
160c96ad1e
pod: persist metadata workspace root 2026-06-19 00:28:19 +09:00
86ce56b941
ticket: dispatch panel workspace pod fixes 2026-06-19 00:25:31 +09:00
6952b265b5
ticket: request panel workspace pod changes 2026-06-19 00:24:47 +09:00
38cc57e7fb
ticket: dispatch panel workspace pod review 2026-06-19 00:16:52 +09:00
7b975aaf93
ticket: record panel workspace pod implementation 2026-06-19 00:16:04 +09:00
3b634d66ca
tui: filter panel pods by workspace 2026-06-19 00:14:02 +09:00
6fe2dcdf8e
ticket: record panel workspace pod coder start 2026-06-18 23:51:56 +09:00
e2e76d3beb
ticket: accept panel workspace pod filter 2026-06-18 23:51:03 +09:00
6cd2af7b72
ticket: close panel dashboard readiness 2026-06-18 23:48:59 +09:00
6d289a583a
ticket: queue 00001KVDH2E06 2026-06-18 23:47:10 +09:00
667873bdbf
merge: sync orchestration before queue 00001KVDH2E06 2026-06-18 23:47:10 +09:00
2d4d11e476
merge: panel dashboard readiness metric 2026-06-18 23:46:59 +09:00
a92dff05f8
ticket: approve panel dashboard readiness 2026-06-18 23:46:59 +09:00
cd07a9d846
ticket: dispatch panel dashboard re-review 2026-06-18 23:42:29 +09:00
cd86cc533c
fix: restore orchestrator companion notifications 2026-06-18 23:42:07 +09:00
656f3fb249
ticket: record panel dashboard fix report 2026-06-18 23:41:52 +09:00
5870251bdf
tui: tighten panel dashboard readiness 2026-06-18 23:39:52 +09:00
ef0c22eae9
ticket: close plugin permission grants 2026-06-18 23:24:50 +09:00
94aa3c1d3b
merge: plugin permission grants 2026-06-18 23:22:49 +09:00
a172d46c90
ticket: approve plugin grants implementation 2026-06-18 23:22:40 +09:00
486ee5f41e
ticket: dispatch panel dashboard review fixes 2026-06-18 23:21:02 +09:00
d98872af4c
ticket: request panel dashboard review changes 2026-06-18 23:20:26 +09:00
20cc77573b
ticket: dispatch reviewers for implementation branches 2026-06-18 23:16:32 +09:00
7ae725c95d
ticket: record coder implementation reports 2026-06-18 23:15:32 +09:00
b1ba15995f
plugin: enforce permission grants 2026-06-18 23:13:40 +09:00
fc1ee5bb55
tui: measure panel dashboard readiness 2026-06-18 23:13:06 +09:00
6c52e5dddf
ticket: record panel dashboard coder start 2026-06-18 22:59:29 +09:00
3b4879446f
ticket: accept panel dashboard latency work 2026-06-18 22:58:41 +09:00
b5f0081566
ticket: record plugin grants coder resume 2026-06-18 22:57:43 +09:00
dcbfb6314e
ticket: queue 00001KVDETSN6 2026-06-18 22:55:08 +09:00
d2833ffded
merge: sync orchestration before queue 00001KVDETSN6 2026-06-18 22:55:08 +09:00
8cbade818f
chore: record panel followups 2026-06-18 22:41:54 +09:00
9ca89250b4
ticket: record plugin grant spawn failure 2026-06-18 22:12:56 +09:00
a984f5809f
ticket: accept plugin permission grants 2026-06-18 22:12:03 +09:00
b6685af3ae
ticket: queue 00001KV5W3PJ3 2026-06-18 22:11:00 +09:00
63d864e1f0
merge: sync orchestration before queue 00001KV5W3PJ3 2026-06-18 22:11:00 +09:00
6641bf4860
ticket: complete plugin wasm runtime 2026-06-18 21:39:40 +09:00
05cd788c13
merge: plugin wasm tool runtime 2026-06-18 21:37:21 +09:00
c05bfaa9c4
ticket: approve plugin wasm runtime 2026-06-18 21:37:21 +09:00
f2d4194f37
ticket: record plugin wasm runtime implementation 2026-06-18 21:31:09 +09:00
10d12148dd
feat: run plugin tools through wasm runtime 2026-06-18 21:29:41 +09:00
ca29cd3b89
ticket: complete panel rows-ready e2e 2026-06-18 21:25:23 +09:00
226eca7a9d
merge: panel rows-ready e2e 2026-06-18 21:24:10 +09:00
49db85c09a
ticket: approve panel rows-ready e2e 2026-06-18 21:24:09 +09:00
7de73dbd33
ticket: record panel rows-ready implementation 2026-06-18 21:19:51 +09:00
fffdfd2721
test: assert panel rows-ready fixture data 2026-06-18 21:17:51 +09:00
54cc87132e
ticket: resume queued implementation pods 2026-06-18 21:06:11 +09:00
46 changed files with 5927 additions and 230 deletions

View File

@ -1,8 +1,8 @@
--- ---
title: "Workspace panel Companion interface" title: "Workspace panel Companion interface"
state: "planning" state: 'closed'
created_at: "2026-06-07T00:16:51Z" created_at: "2026-06-07T00:16:51Z"
updated_at: "2026-06-07T03:13:01Z" updated_at: '2026-06-18T13:06:31Z'
--- ---
## Background ## Background

View File

@ -0,0 +1,10 @@
Closed as completed by child Tickets.
The original Workspace Panel Companion interface plan has been implemented through more specific work:
- direct selected-Pod send was removed from the Panel composer path;
- Panel composer routing now targets the workspace Companion and Ticket Intake explicitly;
- workspace Companion Pod lifecycle restore/spawn/observe behavior is implemented;
- local role/session registry and Ticket claim handling were added for Panel-launched role sessions;
- project role Profile feature defaults limit Companion authority and keep Ticket orchestration / Pods / Task disabled for Companion by default.
The remaining work in this area should be tracked as targeted follow-up Tickets rather than keeping this umbrella planning Ticket open.

View File

@ -74,4 +74,31 @@ Companion work is useful but not required for near-term panel operation. The pan
Decision: downgrade Companion-related follow-up priority to P2 so near-term focus can stay on Ticket role config strictness/init, Orchestrator queue automation, and workflow/compaction reliability. Decision: downgrade Companion-related follow-up priority to P2 so near-term focus can stay on Ticket role config strictness/init, Orchestrator queue automation, and workflow/compaction reliability.
---
<!-- event: state_changed author: hare at: 2026-06-18T13:06:31Z from: planning to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-18T13:06:31Z status: closed -->
## 完了
Closed as completed by child Tickets.
The original Workspace Panel Companion interface plan has been implemented through more specific work:
- direct selected-Pod send was removed from the Panel composer path;
- Panel composer routing now targets the workspace Companion and Ticket Intake explicitly;
- workspace Companion Pod lifecycle restore/spawn/observe behavior is implemented;
- local role/session registry and Ticket claim handling were added for Panel-launched role sessions;
- project role Profile feature defaults limit Companion authority and keep Ticket orchestration / Pods / Task disabled for Companion by default.
The remaining work in this area should be tracked as targeted follow-up Tickets rather than keeping this umbrella planning Ticket open.
--- ---

View File

@ -1,8 +1,8 @@
--- ---
title: 'Plugin: package discovery and explicit enablement resolver' title: 'Plugin: package discovery and explicit enablement resolver'
state: 'done' state: 'closed'
created_at: '2026-06-15T13:40:15Z' created_at: '2026-06-15T13:40:15Z'
updated_at: '2026-06-15T15:30:00Z' updated_at: '2026-06-18T12:22:04Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['plugin', 'package-loading', 'discovery', 'enablement', 'capability-boundary', 'startup-restore'] risk_flags: ['plugin', 'package-loading', 'discovery', 'enablement', 'capability-boundary', 'startup-restore']

View File

@ -0,0 +1,3 @@
Ticket `00001KV5R5V2S` (`Plugin: package discovery and explicit enablement resolver`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -524,4 +524,24 @@ Cleanup planned:
Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused plus packaging validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused plus packaging validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-18T12:22:04Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-18T12:22:04Z status: closed -->
## 完了
Ticket `00001KV5R5V2S` (`Plugin: package discovery and explicit enablement resolver`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
--- ---

View File

@ -1,8 +1,8 @@
--- ---
title: 'Plugin: execute Plugin Tool with minimal WASM runtime' title: 'Plugin: execute Plugin Tool with minimal WASM runtime'
state: 'inprogress' state: 'closed'
created_at: '2026-06-15T14:48:59Z' created_at: '2026-06-15T14:48:59Z'
updated_at: '2026-06-17T09:50:53Z' updated_at: '2026-06-18T13:55:12Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['plugin', 'wasm', 'tool-runtime', 'sandbox', 'capability-boundary', 'cancellation'] risk_flags: ['plugin', 'wasm', 'tool-runtime', 'sandbox', 'capability-boundary', 'cancellation']

View File

@ -0,0 +1,3 @@
Ticket `00001KV5W3PHW` (`Plugin: execute Plugin Tool with minimal WASM runtime`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。

View File

@ -127,4 +127,206 @@ Recovery needed:
State handling: 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-18T12:05:59Z -->
## Implementation report
Implementation resumed:
Previous `SpawnPod` failure was recovered after Orchestrator runtime refresh. Reused existing clean child worktree and successfully spawned Coder Pod.
Worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV5W3PHW-plugin-wasm-tool-runtime`
- branch: `impl/00001KV5W3PHW-plugin-wasm-tool-runtime`
Coder Pod:
- `yoi-coder-00001KV5W3PHW`
No duplicate `queued -> inprogress` transition was performed; this resumes the already accepted in-progress work.
---
<!-- event: implementation_report author: yoi-coder-00001KV5W3PHW at: 2026-06-18T12:31:01Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV5W3PHW`:
Commit:
- `10d12148 feat: run plugin tools through wasm runtime`
Changed files:
- `Cargo.lock`
- `crates/manifest/src/plugin.rs`
- `crates/pod/Cargo.toml`
- `crates/pod/src/feature/plugin.rs`
- `package.nix`
Implemented behavior:
- Added minimal sandboxed Plugin Tool WASM runtime using `wasmi`.
- Enabled registered Plugin Tool calls now execute through normal `Tool::execute` / `ToolOutput` / `ToolResult` paths.
- Added manifest-side helper to load resolved runtime module from pinned package:
- validates runtime kind/ABI;
- re-reads package from resolved path;
- verifies deterministic package digest before selecting runtime entry;
- bounds package/module size and reports safe diagnostics.
- Added `yoi-plugin-wasm-1` host ABI:
- required export: `yoi_tool_call()`;
- required guest export: `memory`;
- host imports under `yoi:tool` only:
- `tool_name_len() -> i32`
- `tool_name_read(ptr, len) -> i32`
- `input_len() -> i32`
- `input_read(ptr, len) -> i32`
- `output_write(ptr, len) -> i32`
- No WASI, filesystem, network, environment, or arbitrary host imports are provided; unsupported imports fail closed.
Bounds / failure behavior:
- Input JSON bound: `64 KiB`.
- Output JSON bound: `64 KiB`.
- Output summary bound: `1 KiB`.
- Linear memory bound: `2 MiB`.
- Table bound: `256`.
- Fuel bound: `5_000_000`.
- Wall timeout: `1s`.
- Diagnostics/errors are bounded and sanitized before surfacing as ordinary Tool errors.
- Malformed input JSON, malformed output JSON, output schema mismatch, oversized output, missing runtime/package/module, unsupported imports, load failure, and non-terminating execution fail closed.
Tests added/updated cover:
- Sample Plugin package Tool executes through WASM runtime.
- Tool input JSON reaches the module.
- Output JSON returns through ordinary Tool result path.
- Malformed input JSON.
- Malformed output JSON.
- Output schema mismatch.
- Oversize output.
- Non-terminating/high-fuel execution fail closed.
- Missing runtime/package/module diagnostic is safe and bounded.
- No ambient WASI/fs/network/env import access.
- Existing Plugin Tool registration behavior.
Validation reported by coder:
- Passed: `cargo fmt --check`
- Passed: `cargo check -p pod`
- Passed: `cargo test -p manifest plugin::`
- Passed: `cargo test -p pod feature::plugin`
- Passed: `git diff --check`
- Passed: `nix build .#yoi`
- Passed: `nix build .#yoi --no-link`
Residual risks / notes:
- ABI is intentionally minimal and not a general Plugin capability model.
- Permission grants, richer host APIs, WASI-like APIs, per-plugin runtime configuration knobs, and schema-driven rich output contracts remain follow-up work.
- Cancellation has no explicit `ToolExecutionContext` cancellation token today; this implementation provides safe timeout/fuel-bounded execution and maps timeout/join failure into ordinary Tool errors.
---
<!-- event: review author: yoi-reviewer-00001KV5W3PHW at: 2026-06-18T12:37:14Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- Child worktree のみ確認。
- `/home/hare/Projects/yoi/.worktree/00001KV5W3PHW-plugin-wasm-tool-runtime`
- implementation commit: `10d12148`
- base: `d32fb3bc`
- 変更箇所を確認。
- `crates/pod/src/feature/plugin.rs`
- `crates/manifest/src/plugin.rs`
- `crates/pod/Cargo.toml`
- `Cargo.lock`
- `package.nix`
- Read-only validation:
- Passed: `git diff --check d32fb3bc..10d12148`
Acceptance review:
- WASM runtime は `wasmi` を使い、WASI setup はなく、host import surface は `yoi_plugin_tool_v1` の最小 input/output 関数だけに限定されている。
- `wasi_snapshot_preview1.fd_write``env.getenv` など ambient-style imports は拒否される。
- Plugin Tools は ordinary `Feature` tool contribution / `ToolExecutor` path で `ToolOutput` / `ToolError` を返し、hidden context injection path は見当たらない。
- module/input/output/summary/error/memory/table/stack/fuel などの bounds が実装されている。
- malformed output、invalid UTF-8、schema mismatch、oversize output は `ToolError` として fail closed する。
- runtime module selection は resolved plugin record、resolved package path、recorded digest、runtime kind/ABI、entry path に基づき、archive digest を再検証してから module bytes を使用している。
- non-termination は fuel exhaustion と runtime timeout wrapper で fail closed する。現行 `ToolExecutionContext` に cancellation token がないため、明示的 cancellation-token integration がない点は、この Ticket の範囲では blocker ではないと判断した。
- `wasmi``default-features = false``std` / `extra-checks` のみ、`wat` は dev-only。WASI/network/process capability library の追加は見当たらない。
Validation not run by reviewer:
- `cargo test` / `cargo check` / `nix build` は read-only review scope では build artifacts を書くため未実行。Coder reported validation を evidence とした。
Risk assessment:
- residual risk は低〜中程度。
- sandbox boundary は Ticket scope に対して妥当。No WASI / no fs / no network / no env imports、bounded IO、bounded diagnostics、deterministic digest check がある。
- `tokio::time::timeout``spawn_blocking` の実行中 task を強制 abort しないが、fuel により guest execution は有限化される。より強い cooperative cancellation semantics は Tool execution API/runtime cancellation model の follow-up として扱うべき。
- Permission grants / richer host API authority は正しく未実装の follow-up に残っている。
Conclusion:
- Approved. No blocker remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T12:39:30Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV5W3PHW-plugin-wasm-tool-runtime`
- implementation commit: `10d12148 feat: run plugin tools through wasm runtime`
- merge commit: `05cd788c merge: plugin wasm tool runtime`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV5W3PHW`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo fmt --check`
- Passed: `cargo check -p pod`
- Passed: `cargo test -p manifest plugin::`
- Passed: `cargo test -p pod feature::plugin`
- 21 passed
- Passed: `git diff --check`
- Passed: `nix build .#yoi --no-link`
Outcome:
- Enabled Plugin Tool calls now execute through minimal `wasmi` runtime with no WASI/fs/network/env host imports.
- Runtime module selection is deterministic from resolved package path/digest/runtime config and rechecks package digest.
- Tool input/output/errors are bounded and returned through ordinary Tool result/error path.
- Missing/malformed/oversize/schema-mismatch/non-terminating execution fails closed with safe Tool errors.
- Permission grants, richer host APIs, WASI-like APIs, and stronger cancellation-token integration remain follow-up scope.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T12:39:30Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation branch merged into the orchestration branch, and focused plus packaging validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: state_changed author: hare at: 2026-06-18T13:55:12Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-18T13:55:12Z status: closed -->
## 完了
Ticket `00001KV5W3PHW` (`Plugin: execute Plugin Tool with minimal WASM runtime`) はすでに `state: done` に到達していたため、workspace Panel から close しました。
この Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。
--- ---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260618-131145-1","ticket_id":"00001KV5W3PJ3","kind":"accepted_plan","accepted_plan":{"summary":"Accept Plugin permission grant enforcement work. Implement typed requested/granted permission matching for Plugin Tool registration/execution and future host API denial diagnostics, fail-closed without implementing fs/https APIs.","branch":"impl/00001KV5W3PJ3-plugin-permission-grants","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5W3PJ3-plugin-permission-grants","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Dependency `00001KV5W3PHW` is done and workspace is clean."},"author":"yoi-orchestrator","at":"2026-06-18T13:11:45Z"}

View File

@ -1,11 +1,13 @@
--- ---
title: 'Plugin: enforce Plugin permission grants' title: 'Plugin: enforce Plugin permission grants'
state: 'ready' state: 'closed'
created_at: '2026-06-15T14:48:59Z' created_at: '2026-06-15T14:48:59Z'
updated_at: '2026-06-15T14:50:28Z' 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']
queued_by: 'workspace-panel'
queued_at: '2026-06-18T13:11:00Z'
--- ---
## Background ## Background

View 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.

View File

@ -5,3 +5,385 @@
LocalTicketBackend によって作成されました。 LocalTicketBackend によって作成されました。
--- ---
<!-- event: state_changed author: workspace-panel at: 2026-06-18T13:11:00Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-18T13:11:45Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。
- `depends_on 00001KV5W3PHW` は done/closed 済みで、Plugin Tool execution path が存在するため typed relation blocker は解消済み。
- 本 Ticket は Plugin manifest の requested permissions と Profile/config grants を照合し、enablement / Tool registration / Tool execution / future host API dispatch の enforcement points を明確にする実装であり、`https` / `fs` host API 実装や broad policy UI は non-goal として明確。
- permission / grant-enforcement / capability-boundary / tool-execution risk は高いが、fail-closed conditions、diagnostics、PreToolCall alignment、external_write handling が Ticket に具体化されているため implementation-ready と判断する。
Evidence checked:
- Ticket body/thread: requirements、initial grant model、acceptance criteria、non-goals、related work を確認。
- Ticket relations: outgoing `depends_on 00001KV5W3PHW` は done/closed。related design `00001KSXRQ4G8` は blocker ではない。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`b6685af3` 上。
- Visible Pods/worktrees: active implementation child なし。
IntentPacket:
Intent:
- Plugin requested permissions と explicit grants を typed model で照合し、Plugin Tool registration/execution と future host API dispatch が grant なしでは fail closed になる boundary を実装する。
Binding decisions / invariants:
- Package presence / discovery / Tool registration だけで execution authority を得ない。
- Requested but not granted は fail closed。
- Unknown permission kind / unsupported grant / overly broad ambiguous grant は fail closed または explicit diagnostic。
- Grant は package ref / source-qualified identity / digest / version と結びつけ、mismatch grant は使わない。
- Permission declarations/grants を ambient workspace FS/network authority として扱わない。
- `https` / `fs` host API の実行実装は non-goal。ただし requested/granted 型と denial diagnostics は扱う。
- Tool effect / external_write metadata は existing permission / PreToolCall path と矛盾させない。
- Denial diagnostics は bounded/safe で、hidden model context injection しない。
Requirements / acceptance criteria:
- Grant なしの Plugin Tool は登録または実行されず safe diagnostic になる。
- Granted Tool だけが登録または実行可能になる。
- Requested surface missing、requested tool missing、external_write missing、host API missing、digest/version/source mismatch、unknown permission kind は fail closed。
- Denied reason が diagnostic / trace で確認できる。
- Existing PreToolCall / Tool permission path と矛盾しない。
- Tests cover no grant denies Plugin Tool execution, grant allows specific Plugin Tool, unrelated package grant does not apply, digest mismatch denies, requested surface missing denies, external_write missing denies, unknown permission kind fails closed, bounded safe denial reason。
Implementation latitude:
- Initial grant model は `surfaces.tool`, tool names/namespaces, external_write flag, `host_api.https`, `host_api.fs` を狭く始める。
- Enforcement point のうち Tool registration と Tool execution のどちらで落とすかは実装判断。ただし model-visible schema gating と runtime execution gating の境界を実装報告に明記する。
- Existing Plugin resolver / Tool surface / WASM runtime code に合わせて typed structs / diagnostics を配置してよい。
Escalate if:
- PreToolCall / Tool metadata public API の broad redesign が必要。
- Grants を安全に package digest/version/source と結合できない。
- Future host API dispatch boundary を追加するには runtime executor redesign が必要。
- Permission UI/policy semantics を product decision として固定する必要が出る。
Validation:
- focused plugin permission/grant tests。
- relevant `cargo test` / `cargo check`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi` if dependencies/Cargo.lock/package changes occur。
Critical risks / reviewer focus:
- fail-closed grant enforcement at registration and execution。
- source/digest/version binding。
- no ambient authority accidentally granted。
- external_write/effect metadata and PreToolCall consistency。
- bounded safe diagnostics。
- Plugin Tool execution path from `00001KV5W3PHW` is gated by grants。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T13:11:51Z from: queued to: inprogress reason: orchestrator_acceptance_dependency_done field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。dependency `00001KV5W3PHW` は done/closed で relation blocker は解消済み。Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T13:12:47Z -->
## Implementation report
Implementation start failure / recovery note:
`queued -> inprogress` acceptance と worktree 作成は完了したが、Coder Pod spawn に失敗した。
Created worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV5W3PJ3-plugin-permission-grants`
- branch: `impl/00001KV5W3PJ3-plugin-permission-grants`
Failure:
- `SpawnPod` failed before child Pod creation.
- Error: `failed to spawn /home/hare/Projects/yoi/target/debug/yoi (deleted) pod: No such file or directory (os error 2)`
Interpretation:
- Runtime binary path for this long-lived Orchestrator process again points at a deleted executable. This is an operational/runtime issue, not a Ticket requirement issue.
- No Coder Pod was started and no implementation commit exists in the child worktree yet.
Recovery needed:
- Refresh/restart the Orchestrator runtime or provide a valid Pod runtime command so `SpawnPod` can launch children again.
- After recovery, reuse the existing clean child worktree/branch and resume Coder spawn for this in-progress Ticket.
State handling:
- 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.
---

View File

@ -1,8 +1,8 @@
--- ---
title: 'Panel startup latency E2E を一覧データ描画完了基準に修正する' title: 'Panel startup latency E2E を一覧データ描画完了基準に修正する'
state: 'inprogress' state: 'done'
created_at: '2026-06-15T16:44:06Z' created_at: '2026-06-15T16:44:06Z'
updated_at: '2026-06-17T09:50:53Z' updated_at: '2026-06-18T13:30:51Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['panel', 'e2e', 'startup-latency', 'readiness-metric', 'ticket-list-rendering'] risk_flags: ['panel', 'e2e', 'startup-latency', 'readiness-metric', 'ticket-list-rendering']

View File

@ -123,4 +123,175 @@ Recovery needed:
State handling: 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-18T12:05:59Z -->
## Implementation report
Implementation resumed:
Previous `SpawnPod` failure was recovered after Orchestrator runtime refresh. Reused existing clean child worktree and successfully spawned Coder Pod.
Worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV62PF32-panel-rows-ready-e2e`
- branch: `impl/00001KV62PF32-panel-rows-ready-e2e`
Coder Pod:
- `yoi-coder-00001KV62PF32`
No duplicate `queued -> inprogress` transition was performed; this resumes the already accepted in-progress work.
---
<!-- event: implementation_report author: yoi-coder-00001KV62PF32 at: 2026-06-18T12:19:45Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV62PF32`:
Commit:
- `fffdfd27 test: assert panel rows-ready fixture data`
Changed files:
- `tests/e2e/src/lib.rs`
- Added concrete `ExpectedPanelTicketRow` matcher.
- Exposed fixture ready/planning Ticket ids/titles/states.
- Added `wait_for_first_visible_frame`, `wait_for_fixture_ticket_rows_ready`, and delayed-row negative assertion helpers.
- `tests/e2e/tests/panel.rs`
- Replaced main startup readiness assertion from `rows.len() >= 2` / “full ready” to concrete ready fixture Ticket row readiness.
- Added matcher regression test proving absent/wrong fixture data is rejected.
- Added delayed reload assertion that first frame can arrive while fixture rows are still not rendered.
- `crates/tui/src/multi_pod.rs`
- Added comments clarifying `panel_ready` is first-visible-frame only, while `rows_rendered` snapshots are the data-backed readiness surface.
Behavior / evidence:
- Main startup readiness now distinguishes:
- first frame: legacy `panel_ready`, can be loading/empty;
- rows ready: concrete `rows_rendered` containing expected fixture Ticket id/title/state/kind;
- full/background completion: still observable via background task events, but no longer the startup readiness proxy.
- Startup readiness assertion is no longer satisfied by first frame alone or by weak `rows.len() >= N` checks.
- Delayed reload test asserts first visible frame can arrive while expected fixture Ticket row remains absent for the delayed window.
Validation reported by coder:
- Passed: `cargo test -p yoi-e2e --features e2e --no-run`
- Passed: `cargo test -p yoi-e2e --features e2e panel -- --nocapture`
- Passed: `cargo check -p yoi-e2e -p yoi -p tui`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
- Not run: `nix build .#yoi` — no runtime/package/dependency changes.
Measured values from focused post-commit reruns:
- `panel_fixture_ticket_row_ready_has_startup_budget`
- first visible frame: `20.403589ms` / budget `1.5s`
- fixture rows ready: `120.605891ms` / budget `5s`
- `panel_first_visible_render_arrives_before_background_reload`
- first visible frame: `20.311085ms` / budget `1.5s`
- reload held; test asserts ready fixture Ticket row remains absent for delayed window before exit.
Repository status:
- Child implementation worktree clean at `fffdfd27`.
Residual gaps:
- None known.
---
<!-- event: review author: yoi-reviewer-00001KV62PF32 at: 2026-06-18T12:24:00Z status: approve -->
## Review: approve
Review result: approve
Evidence checked:
- 対象 worktree / branch / commit が期待通りで clean であることを確認。
- branch: `impl/00001KV62PF32-panel-rows-ready-e2e`
- HEAD: `fffdfd2721fed5171d4dd9780f893b9bb323ab8a`
- base: `d32fb3bc3c82f1cb7e4a29d4fdf6e908ba495c7f`
- Diff scope は以下3ファイルに限定されていることを確認。
- `crates/tui/src/multi_pod.rs`
- `tests/e2e/src/lib.rs`
- `tests/e2e/tests/panel.rs`
Acceptance / intent review:
- Main startup readiness は `panel_fixture_ticket_row_ready_has_startup_budget` で concrete fixture Ticket row readiness を待つ形になっている。
- `rows.len() >= N` ではなく、fixture Ticket row の kind / id / title / status-state を matcher で確認している。
- `panel_ready` / `wait_for_first_visible_frame` は first-visible-frame only としてコメント・helper 名で区別されている。
- `wait_for_fixture_ticket_rows_ready` が startup rows-ready signal として定義されている。
- `panel_fixture_ticket_row_matcher_rejects_absent_fixture_data` により absent/wrong fixture data を reject する挙動が確認されている。
- delayed reload test は first frame が先に出る一方、held reload 中は expected fixture Ticket row が描画されないことを確認している。
- 既存 Panel mouse / row selection E2E は残っており、action dispatch なし・mouse capture behavior の coverage は維持されている。
- fixture isolation / no-provider / no-network 前提は維持されている。
Validation performed by reviewer:
- Passed: `cargo fmt --check`
- Passed: `git diff --check d32fb3bc..HEAD`
Validation not run by reviewer:
- `cargo test` / `cargo check` は read-only review scope では build/artifact 書き込みを伴うため未実行。Coder reported validation を evidence として確認した。
Risk assessment:
- residual risk は低い。変更は test/harness focused で Ticket boundary に沿っている。
- timing test は環境依存性が残るが、readiness predicate は first frame/loading paint ではなく concrete fixture row rendering を測るよう修正されている。
Conclusion:
- Approved. No blocker remains.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T12:25:14Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV62PF32-panel-rows-ready-e2e`
- implementation commit: `fffdfd27 test: assert panel rows-ready fixture data`
- merge commit: `226eca7a merge: panel rows-ready e2e`
Reviewer result:
- `approve` recorded from `yoi-reviewer-00001KV62PF32`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p yoi-e2e --features e2e --no-run`
- Passed: `cargo test -p yoi-e2e --features e2e panel -- --nocapture`
- panel E2E: 6 passed
- first visible frame examples: ~20.34ms / budget 1.5s
- fixture rows ready example: ~120.54ms / budget 5s
- Passed: `cargo check -p yoi-e2e -p yoi -p tui`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Outcome:
- Startup readiness E2E now uses concrete fixture Ticket row render readiness instead of first visible/loading frame or weak row-count checks.
- `panel_ready` remains first-visible-frame only; `rows_rendered` fixture row matching is the data-backed readiness surface.
- Delayed reload coverage verifies first frame can arrive before rows-ready and expected fixture Ticket row remains absent while reload is held.
- Existing Panel mouse/row selection E2E remains covered.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T12:25:14Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved, implementation branch merged into the orchestration branch, and E2E-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
---
<!-- event: review author: hare at: 2026-06-18T13:30:51Z status: request_changes -->
## Review: request changes
Request changes.
The current result still does not answer the user-facing latency problem. The problematic latency is the time from launching `yoi panel` / pressing Enter to seeing the actual workspace dashboard content. The current E2E measures a direct subprocess spawn to one concrete fixture Ticket row appearing in `rows_rendered`; it does not require the dashboard content to be complete from the user's perspective, and it does not reproduce or attribute the clearly long live-workspace delay.
Do not treat fixture first-frame or single-row readiness numbers as evidence that no improvement is needed. The acceptance criterion must be strengthened to a user-visible dashboard-content-ready point and paired with slow-source attribution/improvement for the live-like Panel startup path.
--- ---

View File

@ -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"}

View File

@ -0,0 +1,29 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVDETSN6",
"kind": "related",
"target": "00001KV5D7MG5",
"note": "Dashboard content-ready fixture should include orchestration overlay state.",
"author": "yoi ticket",
"at": "2026-06-18T13:31:43Z"
},
{
"ticket_id": "00001KVDETSN6",
"kind": "related",
"target": "00001KV5MRH6D",
"note": "Follows up Panel startup latency E2E work.",
"author": "yoi ticket",
"at": "2026-06-18T13:31:43Z"
},
{
"ticket_id": "00001KVDETSN6",
"kind": "related",
"target": "00001KV62PF32",
"note": "Supersedes the insufficient single-row rows-ready E2E with user-visible dashboard content-ready measurement.",
"author": "yoi ticket",
"at": "2026-06-18T13:31:43Z"
}
]
}

View File

@ -0,0 +1,105 @@
---
title: 'Panel startup latency をユーザー目線の dashboard content ready 基準で計測・改善する'
state: 'closed'
created_at: '2026-06-18T13:30:51Z'
updated_at: '2026-06-18T14:48:44Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel', 'e2e', 'startup-latency', 'user-visible-readiness', 'dashboard-content', 'profiling']
queued_by: 'workspace-panel'
queued_at: '2026-06-18T13:55:08Z'
---
## Background
ユーザーが問題にしている `yoi panel` startup latency は、first frame や fixture の単一 Ticket row が `rows_rendered` に出るまでではなく、**ユーザーが `yoi panel` を起動してから、workspace dashboard として実際に使えるコンテンツが画面に揃って見えるまで**の時間である。
既存の `00001KV62PF32``panel_ready` / first frame と単一 fixture Ticket row readiness の混同を修正したが、まだ以下の点で不十分だった。
- direct subprocess spawn から単一 fixture Ticket row の `rows_rendered` までを測っているだけで、workspace dashboard 全体の content ready ではない。
- live workspace でユーザーが体感している明らかに長い遅延を再現・属性分解していない。
- fixture 上の約 120ms rows-ready をもって「追加改善不要」と判断してしまうと、ユーザー視点の問題を取り逃がす。
この Ticket では、Panel startup latency の主基準を user-visible dashboard content ready に置き直し、遅延源を計測・改善する。
## Definitions
- `panel_first_frame`: 初回 visible draw。loading / empty frame でもよい補助 metric。
- `fixture_single_row_ready`: 具体的な fixture Ticket row が `rows_rendered` に現れる補助 metric。
- `dashboard_content_ready`: ユーザーが workspace dashboard として必要な主要コンテンツが揃い、実際に画面へ描画された状態。この Ticket の主 metric。
`dashboard_content_ready` は少なくとも以下を含む。
- Ticket rows が fixture / live-like workspace の期待データと一致している。
- id
- title
- state/status
- row kind
- primary action / disabled reason where relevant
- Pod / Companion / Orchestrator 関連 row または status が、fixture / live-like workspace の期待状態と一致している。
- orchestration overlay を含む fixture では、local / orchestration state が表示上も期待通り反映されている。
- loading / empty / partial single-row render だけでは ready とみなさない。
## Requirements
- E2E / harness の readiness event または helper を追加・修正し、`dashboard_content_ready` を測れるようにする。
- first frame / single-row readiness とは別 metric にする。
- event 名・test 名・log 出力から意味が誤解されないようにする。
- 測定開始点は、ユーザーの `yoi panel` 起動に十分近いものにする。
- 基本は `Command::spawn` 直前からでよい。
- interactive shell 入力まで含めない場合は、その範囲を test/report に明記する。
- Fixture を live-like に強化する。
- 複数 Ticket state を含める。
- Pod metadata / Companion / Orchestrator 表示を含める。
- orchestration overlay を含める。
- 必要に応じて stale socket / slow observation / many Ticket records など、実遅延の候補を再現する fixture を追加する。
- `dashboard_content_ready` は単なる `rows.len() >= N` や単一 Ticket row match だけで通さない。
- expected dashboard snapshot / expected row set として比較する。
- 欠落 row、wrong status、wrong action、overlay 未反映を fail にする。
- Live workspace 相当の遅延源を属性分解する。
- Ticket scan / parsing
- orchestration overlay worktree validation / read
- Pod metadata scan
- socket/status probing
- Companion / Orchestrator lifecycle observation
- role session / local claim scan
- git worktree / branch checks
- 明らかに長い遅延がある場合は改善する。
- UI 初期化を content-ready 待ちで止めないだけでは不十分。
- 実コンテンツが揃うまでの経路自体を短くする。
- slow source を lazy / bounded / parallel / cached / timeout-shortened にできる場合は実装する。
- Before / after の実測値を implementation report に記録する。
- first frame
- dashboard content ready
- slow-source breakdown
- fixture 条件 / live-like 条件
- 測定で改善不要と判断する場合でも、ユーザーが見ている長い live latency がなぜ再現しないか、またはどの範囲外かを明示する。
## Acceptance criteria
- E2E が `dashboard_content_ready` を主 startup latency metric として測る。
- `panel_first_frame` または単一 Ticket row readiness だけでは、この Ticket の主 E2E は通らない。
- Expected dashboard snapshot に含まれる Ticket / Pod / Companion / Orchestrator / overlay 要素が揃って描画された時点を ready として扱う。
- Missing row / wrong state / missing overlay / missing action label の fixture では ready 判定が fail する。
- User-visible dashboard content ready の before / after 実測値が記録される。
- 遅延源の breakdown が記録され、主要 slow source に対して具体的な改善または明示的な non-action rationale がある。
- Live-like fixture または current workspace に近い条件で、ユーザー体感の長い遅延を取り逃がさない。
- Existing Panel behavior に regression がない。
- row selection
- composer target
- Queue action
- orchestration overlay display
- Validation: relevant `cargo test -p yoi-e2e --features e2e panel`, `cargo check`, `cargo fmt --check`, `git diff --check`, and `nix build .#yoi` if code/package/runtime behavior changes.
## Non-goals
- Interactive shell の command lookup / prompt rendering まで含めた OS/shell latency の厳密測定。
- すべての background observation が完全 settle するまで UI を出さないこと。
- Panel architecture の全面刷新。
- Ticket lifecycle semantics の変更。
## Related work
- `00001KV62PF32` — Panel startup latency E2E を一覧データ描画完了基準に修正する。単一 fixture row readiness までで不十分だったため request-changes 済み。
- `00001KV5MRH6D` — Panel startup latency E2E / first visible frame separation work。
- `00001KV5D7MG5` — Panel orchestration worktree Ticket state overlay。

View File

@ -0,0 +1,29 @@
Ticket `00001KVDETSN6` is complete.
Completed implementation:
- Added a user-visible `dashboard_content_ready` metric that carries and validates a rendered dashboard snapshot rather than treating first frame or a single row as ready.
- Added expected dashboard content coverage for Ticket rows, Pod rows, Companion status, Orchestrator status, orchestration overlay state, action labels, disabled reasons, local Ticket state, and overlay Ticket state.
- Added a real git/orchestration overlay fixture for the local-ready / overlay-inprogress case and validated the visible `ready→prog` / `Wait` row.
- Added negative coverage for missing expected row, wrong state, missing overlay state, and missing action label.
- Added bounded startup source breakdown including pod metadata/status probing, ticket config probe/parse, overlay validation/read/git checks, ticket scan/parse, local claim scan, pod row materialization, and total workspace panel build.
Reviewed / merged:
- Initial implementation: `fc1ee5bb` (`tui: measure panel dashboard readiness`)
- Review-fix implementation: `5870251b` (`tui: tighten panel dashboard readiness`)
- First review requested changes; blockers were fixed.
- Re-review approved with no remaining blockers.
- Orchestrator merge commit: `2d4d11e4` (`merge: panel dashboard readiness metric`)
Validation in Orchestrator 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
Cleanup:
- Stopped Coder Pod `yoi-coder-00001KVDETSN6`.
- Stopped Reviewer Pod `yoi-reviewer-00001KVDETSN6-r2`.
- Removed child worktree `/home/hare/Projects/yoi/.worktree/00001KVDETSN6-panel-dashboard-content-ready`.
- Deleted merged branch `impl/00001KVDETSN6-panel-dashboard-content-ready`.
Root/original workspace was not read/written/merged/validated for this Ticket, per Panel Queue instruction. The completed work is integrated on the Orchestrator branch.

View File

@ -0,0 +1,540 @@
<!-- event: create author: "yoi ticket" at: 2026-06-18T13:30:51Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-18T13:55:08Z from: ready to: queued reason: queued field: state -->
## State changed
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.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:47:55Z -->
## Implementation report
Merge / validation report:
Implementation branch `impl/00001KVDETSN6-panel-dashboard-content-ready` を Orchestrator worktree の `orchestration` branch に merge した。
Merge:
- merge commit: `2d4d11e4``merge: panel dashboard readiness metric`
- implementation commits:
- `fc1ee5bb``tui: measure panel dashboard readiness`
- `5870251b``tui: tighten panel dashboard readiness`
- changed files:
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/workspace_panel.rs`
- `tests/e2e/src/lib.rs`
- `tests/e2e/tests/panel.rs`
Validation in Orchestrator 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
Review:
- First review returned `request_changes` and blockers were sent back to Coder.
- Re-review `yoi-reviewer-00001KVDETSN6-r2` returned `approve` with no remaining blockers.
Outcome:
- `dashboard_content_ready` now validates expected dashboard snapshot content including Ticket / Pod / Companion / Orchestrator / overlay-visible state and action/disabled fields.
- first-frame / single-row readiness no longer satisfies the main metric.
- negative coverage covers missing row, wrong state, missing overlay, and missing action label.
- slow-source breakdown is bounded and more diagnostic.
- Root/original workspace was not read/written/merged/validated for this Ticket, per Panel Queue instruction.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:48:21Z -->
## Implementation report
Cleanup report:
Panel dashboard readiness Ticket の implementation/review Pods と child worktree を cleanup した。
Stopped Pods:
- `yoi-coder-00001KVDETSN6`
- `yoi-reviewer-00001KVDETSN6-r2`
Removed child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVDETSN6-panel-dashboard-content-ready`
Deleted merged implementation branch:
- `impl/00001KVDETSN6-panel-dashboard-content-ready` at `5870251b`
Orchestrator branch remains the integration authority for this completed work. Root/original workspace was not touched for this Ticket, per Panel Queue instruction.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T14:48:30Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Implementation commits `fc1ee5bb` and `5870251b` were reviewed after blocker resolution, approved, merged into the Orchestrator branch as `2d4d11e4`, validated in the Orchestrator worktree, and child implementation resources were cleaned up. Requirements and acceptance criteria are satisfied for this Ticket. Root/original workspace was not operated on for this Ticket per Panel Queue instruction.
---
<!-- event: state_changed author: hare at: 2026-06-18T14:48:44Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-18T14:48:44Z status: closed -->
## 完了
Ticket `00001KVDETSN6` is complete.
Completed implementation:
- Added a user-visible `dashboard_content_ready` metric that carries and validates a rendered dashboard snapshot rather than treating first frame or a single row as ready.
- Added expected dashboard content coverage for Ticket rows, Pod rows, Companion status, Orchestrator status, orchestration overlay state, action labels, disabled reasons, local Ticket state, and overlay Ticket state.
- Added a real git/orchestration overlay fixture for the local-ready / overlay-inprogress case and validated the visible `ready→prog` / `Wait` row.
- Added negative coverage for missing expected row, wrong state, missing overlay state, and missing action label.
- Added bounded startup source breakdown including pod metadata/status probing, ticket config probe/parse, overlay validation/read/git checks, ticket scan/parse, local claim scan, pod row materialization, and total workspace panel build.
Reviewed / merged:
- Initial implementation: `fc1ee5bb` (`tui: measure panel dashboard readiness`)
- Review-fix implementation: `5870251b` (`tui: tighten panel dashboard readiness`)
- First review requested changes; blockers were fixed.
- Re-review approved with no remaining blockers.
- Orchestrator merge commit: `2d4d11e4` (`merge: panel dashboard readiness metric`)
Validation in Orchestrator 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
Cleanup:
- Stopped Coder Pod `yoi-coder-00001KVDETSN6`.
- Stopped Reviewer Pod `yoi-reviewer-00001KVDETSN6-r2`.
- Removed child worktree `/home/hare/Projects/yoi/.worktree/00001KVDETSN6-panel-dashboard-content-ready`.
- Deleted merged branch `impl/00001KVDETSN6-panel-dashboard-content-ready`.
Root/original workspace was not read/written/merged/validated for this Ticket, per Panel Queue instruction. The completed work is integrated on the Orchestrator branch.
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260618-144957-1","ticket_id":"00001KVDH2E06","kind":"accepted_plan","accepted_plan":{"summary":"`yoi panel` の通常 Pod rows/action target を、persisted Pod metadata / resolved runtime workspace root に基づき現在 workspace 所属の Pod に限定する。別 workspace / 判定不能 / corrupt metadata は通常一覧に混ぜず、current workspace の Companion/Orchestrator/role Pod と worktree cwd の Pod は残す。","branch":"impl/00001KVDH2E06-panel-current-workspace-pods","worktree":"/home/hare/Projects/yoi/.worktree/00001KVDH2E06-panel-current-workspace-pods","role_plan":"Orchestrator は専用 implementation worktree を作成し、Coder をその worktree への narrow write scope で起動する。Reviewer は実装報告後に read-only で確認する。現在 inprogress Ticket はなく、Panel/TUI/Pod metadata 近傍の単独実装として受理する。"},"author":"yoi-orchestrator","at":"2026-06-18T14:49:57Z"}

View File

@ -0,0 +1,96 @@
---
title: 'Panel 表示を現在 workspace の Pod に限定する'
state: 'closed'
created_at: '2026-06-18T14:09:59Z'
updated_at: '2026-06-18T15:40:38Z'
assignee: null
readiness: 'ready'
risk_flags: ['panel', 'pod-metadata', 'workspace-boundary', 'runtime-observation']
queued_by: 'workspace-panel'
queued_at: '2026-06-18T14:47:10Z'
---
## Background
`yoi panel` は workspace の状況確認・Ticket queue・Orchestrator 操作の入口になっているため、別 workspace の Pod が一覧に混ざると、現在 workspace の状態として誤認しやすい。
Yoi では runtime workspace root、Pod identity、Profile、cwd、Ticket backend checkout が別概念として整理されている。したがって Panel の表示対象も、Pod 名や cwd の heuristic ではなく、Pod metadata に記録された runtime workspace identity を基準に workspace-scoped にする必要がある。
Request snapshot:
- `yoi panel` の Pod 表示で、現在の workspace に属さない Pod が表示されないようにしたい。
- Panel handoff context:
- workspace: `yoi`
- workspace_orchestrator_pod: `yoi-orchestrator`
## Requirements
- `yoi panel` の通常 Pod 表示は、現在の runtime workspace root に属する Pod のみに限定する。
- 別 workspace の Pod は Panel の通常一覧・通常 action target に表示しない。
- workspace Orchestrator Pod、Companion Pod、Ticket role Pod など、現在 workspace に属する role Pod は表示対象に残す。
- dedicated orchestration worktree や implementation worktree を cwd にしている Pod でも、runtime workspace が現在 workspace なら表示対象に残す。
- Pod 名 prefix や cwd だけで workspace 所属を推測しない。可能な限り persisted Pod metadata / resolved runtime workspace root を authority とする。
- metadata が壊れている、または workspace 判定不能な Pod は、通常一覧に混ぜず、必要なら bounded diagnostic として扱う。
- no-Ticket workspace / Pod-centric fallback でも、現在 workspace の Pod discovery と attach/open は維持する。
- Ticket rows / queue actions / Panel Orchestrator lifecycle は維持する。
## Acceptance criteria
- `yoi panel` を workspace `A` で開いたとき、workspace `B` の Pod が通常 Pod list に表示されない。
- 現在 workspace の `workspace_orchestrator_pod`、Companion、Ticket role Pod は引き続き表示・操作できる。
- cwd が `.worktree/...` 配下でも、runtime workspace が現在 workspace なら隠されない。
- workspace 判定不能な legacy/corrupt Pod metadata が、現在 workspace の通常 row として誤表示されない。
- Panel の attach/open は、表示されている現在 workspace Pod に対して従来通り機能する。
- Focused unit tests または E2E/fixture tests で、複数 workspace の Pod metadata が存在する場合に Panel が現在 workspace の Pod だけを表示することを確認する。
## Binding decisions / invariants
- Panel 表示スコープの authority は、runtime workspace root / Pod metadata に基づく。Pod name prefix や process cwd のみでは判定しない。
- `role_workspace_root` / `original_workspace_root` / `implementation_worktree_root` / `merge_target_workspace_root` は混同しない。
- dedicated orchestration worktree を使う Orchestrator は、cwd が workspace 外に見えても、runtime workspace が元 workspace なら表示対象に残す。
- ワークスペース外 Pod を通常一覧に出してから UI 上で注意表示するのではなく、通常一覧から除外する。
- 別 workspace の Pod に対する attach/open/action path を Panel から提供しない。
- 既存 Pod metadata の破壊的 migration はこの Ticket の範囲外。必要なら escalation する。
- ユーザー承認により、metadata が古く workspace 判定不能な Pod は通常 Panel 表示から隠し、必要なら diagnostic にだけ出す。
## Implementation latitude
- workspace root の canonicalization / path comparison の具体実装は Coder が調査して選んでよい。
- 判定不能 Pod の diagnostic 表示方法は、既存 Panel diagnostic pattern に合わせてよい。
- 既存 ViewModel / Pod listing abstraction のどこで filter するかは実装調査に任せてよい。
- E2E が重すぎる場合、まず unit/fixture test で複数 workspace Pod metadata を作る focused coverage でもよい。ただし user-visible Panel 挙動の確認手段は残す。
## Readiness
- readiness: implementation_ready
- risk_flags: [panel, pod-metadata, workspace-boundary, runtime-observation]
- open_questions: none
## Escalation conditions
- 既存 metadata に runtime workspace root が十分に保存されておらず、正しい workspace 判定に schema/storage 変更が必要な場合。
- legacy Pod を隠すことで既存の復元/attach 導線が実用上失われる場合。
- workspace root canonicalization で symlink / moved checkout / worktree の扱いに人間判断が必要な場合。
- Panel の no-Ticket fallback が「全 Pod dashboard」であるべきか「current workspace Pod dashboard」であるべきか、既存設計と衝突する場合。
- 別 workspace Pod を診断用に見せる必要が出た場合。その場合も通常 action row とは分離する。
## Validation
- Focused tests for Panel ViewModel / Pod list filtering:
- current workspace Pod is visible;
- other workspace Pod is hidden;
- workspace Orchestrator / role Pod for current workspace is visible;
- cwd/worktree difference alone does not hide current workspace Pod;
- unknown/corrupt workspace metadata is not treated as current workspace.
- Existing Panel/TUI tests continue to pass.
- If practical: Panel E2E fixture with multiple workspace Pod metadata records.
- `cargo fmt --check`
- `git diff --check`
- relevant `cargo test` / `cargo check`
## Related work
- `00001KTFQ109S`: Workspace panel Companion interface。
- `00001KTFQ109V`: Remove workspace panel direct Pod send。
- `00001KV0YK5S0`: E2E harness を完全な tmp runtime/data/workspace 隔離と cleanup に対応させる。
- Runtime workspace / Pod identity decisions in existing memory and related closed runtime-workspace Tickets。

View File

@ -0,0 +1,40 @@
Ticket `00001KVDH2E06` is complete.
Completed implementation:
- Normal `yoi panel` Pod rows and Pod action targets are filtered to the current runtime workspace using persisted Pod metadata / runtime workspace ownership.
- Added optional persisted `workspace_root` to `PodMetadata` and ensured normal metadata write-through persists it.
- Added `PodMetadataStore::set_active_with_workspace_root(...)` while keeping compatibility `set_active(...)` preserving existing workspace ownership.
- Workspace-external Pods are excluded from normal Panel rows/action targets.
- Unknown/corrupt/live-only metadata fail closed and are not treated as current workspace Pods.
- Current-workspace role-like Pods remain visible when persisted runtime workspace matches, independent of cwd/worktree differences.
Reviewed / merged:
- Initial implementation: `3b634d66` (`tui: filter panel pods by workspace`)
- Persistence-boundary fix: `160c96ad` (`pod: persist metadata workspace root`)
- First review requested changes for missing persistence of `workspace_root` through the normal store writer path.
- Re-review approved with no remaining blockers.
- Orchestrator merge commit: `0ef36b4e` (`merge: panel workspace pod filter`)
Validation in Orchestrator worktree:
- `cargo fmt --check` — passed
- `cargo check -p tui -p pod -p pod-store` — passed
- `cargo test -p pod metadata_writer_persists_workspace_root_through_store_update -- --nocapture` — passed
- `cargo test -p pod pod_metadata -- --nocapture` — passed
- `cargo test -p pod-store` — passed; 6 passed, 0 failed
- `cargo test -p tui workspace_panel -- --nocapture` — passed; 23 passed, 0 failed
- `cargo test -p tui pod_list -- --nocapture` — passed; 19 passed, 0 failed
- `git diff --check` — passed
Known validation debt:
- Full `cargo test -p tui` still has three pre-existing/unrelated failures reproduced on the Orchestrator branch before this implementation was merged:
- `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`
- `spawn::tests::profile_choices_use_project_registry_default`
- `spawn::tests::profile_choices_include_builtin_and_project_default_marker`
Cleanup:
- Stopped Coder Pod `yoi-coder-00001KVDH2E06`.
- Stopped Reviewer Pod `yoi-reviewer-00001KVDH2E06-r2`.
- Removed child worktree `/home/hare/Projects/yoi/.worktree/00001KVDH2E06-panel-current-workspace-pods`.
- Deleted merged branch `impl/00001KVDH2E06-panel-current-workspace-pods`.
Root/original workspace was not read/written/merged/validated for this Ticket, per Panel Queue instruction. The completed work is integrated on the Orchestrator branch.

View File

@ -0,0 +1,569 @@
<!-- event: create author: ticket-intake at: 2026-06-18T14:09:59Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: ticket-intake at: 2026-06-18T14:10:12Z -->
## Intake summary
ユーザー承認済み。`yoi panel` の通常 Pod 表示を現在の runtime workspace に属する Pod だけに限定する concrete work item。workspace 外 Pod は通常一覧/action target から除外し、workspace 判定不能な legacy/corrupt metadata は通常表示せず bounded diagnostic のみ許容する。受け入れ条件・binding invariants・validation は Ticket body に記録済み。
---
<!-- event: state_changed author: ticket-intake at: 2026-06-18T14:10:12Z from: planning to: ready reason: user_approved_intake_ready field: state -->
## State changed
Ticket intake が完了しました。実装起動は Orchestrator routing / queue flow に委ねます。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-18T14:47:10Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-18T14:50:44Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により、この Ticket は Orchestrator routing 対象として明示許可された。
- Ticket body には、workspace-scoped Pod visibility の intent、binding invariants、acceptance criteria、implementation latitude、validation、escalation conditions が揃っている。
- Risk domain は Panel / Pod metadata / workspace boundary / runtime observation だが、Ticket では authority を runtime workspace root / persisted Pod metadata に固定しており、Pod name prefix / cwd heuristic を禁止している。bounded context check 後も implementation 前に必要な追加の human decision は見つからなかった。
- `depends_on` / incoming `blocks` の blocker はない。OrchestrationPlan には既存 blocker/conflict record はなく、今回 accepted plan を記録済み。
- 現在 inprogress Ticket は 0 件で、visible child Pod もない。Orchestrator worktree は clean、同名 branch/worktree は存在しない。
Evidence checked:
- Ticket `00001KVDH2E06` body / thread / artifacts。
- `TicketRelationQuery(00001KVDH2E06)`: relation 0 件、blocking acceptance blocker なし。
- `TicketOrchestrationPlanQuery(00001KVDH2E06)`: 既存 record なし。今回 `accepted_plan` を記録済み。
- `TicketList`: queued は本 Ticket 1 件、inprogress は 0 件。
- Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: clean。
- Existing worktree/branch: `00001KVDH2E06` / panel-current-workspace-pods matching branch/worktree なし。
- Visible Pods: self `yoi-orchestrator` のみ。
- Current code map:
- `crates/tui/src/pod_list.rs`: stored/live Pod metadata を `PodListEntry` に merge して action/diagnostics を作る。
- `crates/tui/src/workspace_panel.rs`: `build_workspace_panel*``pod_rows(pods)` が Pod rows を Panel に入れている。
- `crates/pod-store/src/lib.rs`: durable `PodMetadata``resolved_manifest_snapshot` を持ち、runtime workspace 判定の authority candidate になる。
- Current `pod_rows``pods.entries.iter().map(pod_row)` で、workspace filter が見当たらない。
IntentPacket:
Intent:
- `yoi panel` の通常 Pod 表示と Pod action target を、現在の runtime workspace に属する Pod に限定する。
- 別 workspace の Pod、workspace 判定不能な legacy/corrupt metadata を通常 Pod rows に混ぜない。
- Current workspace の Companion / Orchestrator / Ticket role Pod は、cwd が dedicated orchestration worktree や implementation worktree でも、runtime workspace が現在 workspace なら表示対象に残す。
Binding decisions / invariants:
- Authority は runtime workspace root / persisted Pod metadata / resolved runtime workspace identity。Pod name prefix や process cwd だけで判定しない。
- `role_workspace_root` / `original_workspace_root` / `implementation_worktree_root` / `merge_target_workspace_root` を混同しない。
- Workspace 外 Pod は通常一覧に出して warning するのではなく通常一覧から除外する。
- 別 workspace Pod に対する attach/open/action path を Panel から提供しない。
- Corrupt / unknown workspace metadata は current workspace とみなさない。必要なら bounded diagnostic に留める。
- 既存 Pod metadata の破壊的 migration はこの Ticket の範囲外。
- Panel Ticket rows / queue actions / Orchestrator lifecycle semantics は維持する。
Requirements / acceptance criteria:
- workspace A で `yoi panel` を開いたとき workspace B の Pod が通常 Pod list に表示されない。
- Current workspace の workspace Orchestrator / Companion / Ticket role Pod は表示・操作できる。
- cwd が `.worktree/...` 配下でも runtime workspace が current workspace なら隠されない。
- Unknown/corrupt workspace metadata は通常 current-workspace Pod row として誤表示されない。
- Attach/open は表示されている current workspace Pod に対して従来通り機能する。
- Focused tests または E2E/fixture tests で multiple workspace Pod metadata の filter を確認する。
Implementation latitude:
- workspace root canonicalization / path comparison の具体実装は Coder が既存 pattern に合わせて選んでよい。
- Filtering location は `PodList` 側または `WorkspacePanelViewModel` construction 側のどちらでもよいが、normal Panel rows/action target に外部 workspace Pod が混ざらないこと。
- 判定不能 Pod の diagnostic 表示は既存 Panel diagnostics pattern に合わせてよい。
- E2E が重すぎる場合は unit/fixture coverage を優先してよい。ただし user-visible Panel behavior の確認手段は残す。
Escalate if:
- Existing metadata / resolved manifest snapshot に runtime workspace root が十分に保存されておらず、schema/storage 追加や destructive migration が必要。
- Legacy Pod を隠すことで restore/attach 導線を維持できない。
- canonicalization で symlink / moved checkout / worktree treatment の product decision が必要。
- no-Ticket fallback が all-Pod dashboard であるべきか current-workspace dashboard であるべきか、既存設計と衝突する。
- 別 workspace Pod を診断用に通常 action row とは別に見せる必要が出る。
Validation:
- Focused tests for Panel ViewModel / Pod list filtering:
- current workspace Pod visible;
- other workspace Pod hidden;
- current workspace Orchestrator/role Pod visible;
- cwd/worktree difference alone does not hide current workspace Pod;
- unknown/corrupt workspace metadata is not treated as current workspace。
- Existing Panel/TUI tests continue to pass。
- If practical: Panel E2E fixture with multiple workspace Pod metadata records。
- `cargo fmt --check`
- `git diff --check`
- relevant `cargo test` / `cargo check`
Critical risks / reviewer focus:
- workspace filter が cwd/name heuristic になっていないか。
- metadata 判定不能 Pod を current workspace と誤認していないか。
- dedicated orchestration / implementation worktree cwd の current workspace Pod を誤って隠していないか。
- attach/open/action target が hidden external Pod に残っていないか。
- no-Ticket fallback と Ticket-enabled Panel の両方で current workspace Pod discovery が維持されているか。
Next action:
- `queued -> inprogress` を記録し、Ticket records を Orchestrator worktree に commit してから、専用 implementation worktree を作成し Coder Pod を narrow write scope で起動する。root/original workspace は操作しない。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T14:50:54Z from: queued to: inprogress reason: orchestrator_acceptance_current_workspace_pod_filter field: state -->
## State changed
Ticket body/thread, relation metadata, orchestration plan records, Orchestrator worktree, visible Pods, existing branch/worktree, and bounded code context were checked. No blocking relation, conflict, dirty-state blocker, or missing planning decision was found. Risk flags are captured as invariants/reviewer focus rather than stop gates. Accepting this queued Ticket for implementation before worktree/Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T14:51:50Z -->
## Implementation report
Implementation start note:
`queued -> inprogress` acceptance、accepted plan、routing decision / IntentPacket を記録し、Orchestrator worktree で commit した後に、専用 implementation worktree と Coder Pod を起動した。
Worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVDH2E06-panel-current-workspace-pods`
- branch: `impl/00001KVDH2E06-panel-current-workspace-pods`
Coder Pod:
- `yoi-coder-00001KVDH2E06`
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 は current-workspace Pod filtering、focused tests、validation、implementation commit を行い、実装報告を返す予定。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T15:15:57Z -->
## Implementation report
Coder implementation report received:
Commit:
- `3b634d66``tui: filter panel pods by workspace`
Summary:
- `PodMetadata` に optional persisted `workspace_root` を追加し、新規 metadata write 時に Pod runtime workspace を保存するようにした。
- persisted metadata に基づく workspace-aware Pod list construction/filtering を追加。Pod name prefix や cwd heuristic では判定しない。
- `yoi panel` の normal Pod rows / action targets を current runtime workspace metadata で filter するようにした。
- legacy / unknown / corrupt workspace metadata は normal Panel Pod rows では fail-closed にした。
- current-workspace Companion / Orchestrator / Ticket role Pod は、stored runtime workspace が current workspace と一致すれば worktree/cwd 差分に関係なく表示対象に残る。
Files changed:
- `crates/pod-store/src/lib.rs`
- `crates/pod/src/pod.rs`
- `crates/pod/src/discovery.rs`
- `crates/pod/src/ticket_event_notify.rs`
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/pod_list.rs`
- `crates/tui/src/workspace_panel.rs`
Focused validation reported by Coder:
- `cargo test -p tui workspace_panel -- --nocapture` — passed; 23 tests
- `cargo test -p tui pod_list -- --nocapture` — passed; 19 tests
- `cargo test -p pod-store` — passed; 6 tests
- `cargo test -p pod pod_metadata -- --nocapture` — passed targeted Pod metadata tests and filtered integration tests
- `cargo check -p tui -p pod -p pod-store` — passed
- `cargo fmt --check` — passed
- `git diff --check` — passed
Full-suite note:
- Coder also attempted `cargo test -p tui`; it failed with 3 tests outside the focused scope:
- `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`
- `spawn::tests::profile_choices_include_builtin_and_project_default_marker`
- `spawn::tests::profile_choices_use_project_registry_default`
- Orchestrator re-ran these failing tests in both the implementation worktree and the current Orchestrator worktree before this implementation was merged. The same failures reproduce on the Orchestrator branch, so they are currently treated as pre-existing validation debt rather than blockers for this Ticket, unless review finds the implementation worsens their semantics.
Orchestrator evidence checked:
- Pod output を確認。
- implementation worktree は clean。
- branch `impl/00001KVDH2E06-panel-current-workspace-pods` HEAD は `3b634d66`
- diff stat from accepted base `e2e76d3b..3b634d66`: 7 files changed, 292 insertions, 14 deletions。
- Focused failing-test comparison:
- `cargo test -p tui multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace -- --nocapture` fails identically on implementation worktree and Orchestrator worktree.
- `cargo test -p tui spawn::tests::profile_choices -- --nocapture` fails identically on implementation worktree and Orchestrator worktree.
Routing classification: review_needed
Next action:
- recorded intent / invariants / acceptance criteria に照らして、read-only Reviewer Pod で外部レビューする。
- 特に workspace authority が runtime workspace metadata であり cwd/name heuristic ではないこと、unknown/corrupt metadata の fail-closed、current workspace role Pod visibility、hidden external Pod に action target が残らないこと、optional `workspace_root` metadata の compatibility を確認する。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-18T15:16:45Z -->
## Plan
Review dispatch:
Reviewer Pod `yoi-reviewer-00001KVDH2E06` を read-only scope で起動した。
Review target:
- worktree: `/home/hare/Projects/yoi/.worktree/00001KVDH2E06-panel-current-workspace-pods`
- branch: `impl/00001KVDH2E06-panel-current-workspace-pods`
- commit: `3b634d66`
Reviewer focus:
- recorded intent / invariants / acceptance criteria に対する適合性。
- workspace ownership authority が persisted runtime workspace metadata / resolved identity に基づき、Pod name prefix / cwd heuristic ではないこと。
- external workspace Pod が normal rows と normal action targets から除外されること。
- current workspace Companion / Orchestrator / Ticket role Pod が cwd/worktree difference によって誤って隠されないこと。
- unknown/corrupt/legacy metadata が fail-closed で current workspace と誤認されないこと。
- optional `workspace_root` metadata が non-destructive / compatible であること。
- focused tests が current visible / external hidden / role visible / worktree cwd difference / live-only legacy corrupt hidden / Panel row-action filtering を十分に覆っていること。
- `cargo test -p tui` の 3 failure は Orchestrator branch でも同様に再現するため pre-existing debt と見てよいか、それともこの branch が semantic に悪化させているか。
Next:
- Reviewer output を確認し、approve なら Orchestrator worktree へ merge/validation/cleanup/close-ready 処理へ進む。request_changes なら blocker を記録して Coder に戻す。
---
<!-- event: review author: yoi-reviewer-00001KVDH2E06 at: 2026-06-18T15:24:39Z status: request_changes -->
## Review: request changes
Review result: request_changes
対象:
- Ticket: `00001KVDH2E06`
- branch: `impl/00001KVDH2E06-panel-current-workspace-pods`
- commit: `3b634d66` (`tui: filter panel pods by workspace`)
Blocker:
- runtime metadata 用の `workspace_root` は計算されているが、normal metadata write-through path で永続化される前に捨てられている。
Evidence:
- `crates/pod/src/pod.rs:4320-4327` では `PodMetadata::new(...).with_workspace_root(workspace_root.to_path_buf())` を構築している。
- しかし `pod_metadata_writer_for_store` (`crates/pod/src/pod.rs:68-80`) は `store.set_active(&metadata.pod_name, metadata.active, metadata.resolved_manifest_snapshot)` だけを呼び、`metadata.workspace_root` を渡していない。
- `PodMetadataStore::set_active` / `update_by_name` (`crates/pod-store/src/lib.rs:154-178`) は absent record を `PodMetadata::new(pod_name, None)` で作り、`active` / `resolved_manifest_snapshot` だけを更新する。そのため、新規 metadata file は `workspace_root: None` のままになる。
- constructors/write-through paths、例: `write_pod_metadata_pending` はこの writer を使うため、normal runtime path では新しい workspace ownership field が永続化されないように見える。
Why this breaks acceptance:
- `crates/tui/src/pod_list.rs:68-95` は fail-closed 設計で、stored Pods は `info.workspace_root.is_some_and(...)` の場合だけ含め、live Pods も stored current-workspace record と name が一致した場合だけ含める。
- normal write path が current-workspace Pods を `workspace_root: None` のままにすると、新規 current-workspace Pods が unknown/legacy として扱われ、normal Panel rows/action targets から隠れる。
- これは current-workspace Companion / Orchestrator / Ticket role Pods を表示するという acceptance criteria を直接壊す。
Positive findings:
- filter logic の方向性は正しい。persisted `workspace_root` を使っており、Pod name / live cwd heuristic ではない。
- `workspace_root: Option<PathBuf>` に serde default / skip-serializing-if を付ける設計は old metadata に対して non-destructive に見える。
- tests は pure Panel/PodList filtering fixtures を覆っているが、runtime metadata with `workspace_root` が normal Pod runtime path で実際に永続化される persistence boundary を見逃している。
Requested changes:
- write-through path で `metadata.workspace_root` を永続化すること。
- 例: `set_active` を拡張する、または active/snapshot/workspace ownership を同時に更新する store method を追加する。
- unrelated fields は保持すること。
- normal Pod metadata write-through を構築/実行し、stored metadata に runtime workspace root が残ることを確認する targeted test を追加する。
- Filtering fixture だけではなく、`PodMetadata::with_workspace_root` が存在するだけでもなく、normal runtime/store boundary の永続化を検証すること。
Validation note:
- Reviewer は read-only inspection を実施。Coder の focused validation は確認した。
- `cargo test -p tui` の既知 3 failures について、この branch が原因/悪化とは見ていない。ただし上記 persistence blocker は独立して修正が必要。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-18T15:25:21Z -->
## Plan
Review follow-up dispatch:
Reviewer `yoi-reviewer-00001KVDH2E06``request_changes` を返したため、Ticket review として記録し、既存 Coder Pod `yoi-coder-00001KVDH2E06` に修正依頼を送った。
Coder に依頼した blocker:
- `workspace_root` は runtime metadata 構築時に計算されるが、normal metadata write-through path で `PodMetadataStore::set_active` / `update_by_name` に渡されず、永続化されていない。
- その結果、新規 current-workspace Pod metadata が `workspace_root: None` になり、fail-closed filter により current-workspace Companion / Orchestrator / Ticket role Pod まで normal Panel rows/action targets から隠れる可能性がある。
Required fixes:
- `metadata.workspace_root` を write-through path で永続化する。
- active/snapshot/workspace ownership を同時に更新する API 追加または既存 API 拡張で、unrelated fields は保持する。
- normal Pod metadata write-through/store boundary を通して、stored metadata に runtime workspace root が残る targeted test を追加する。
Next:
- Coder の修正 commit / validation report を待つ。
- 修正後、同じ persistence boundary と filtering semantics を中心に再レビューする。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T15:29:08Z -->
## Implementation report
Coder review-fix implementation report received:
Incremental fix commit:
- `160c96ad``pod: persist metadata workspace root`
Summary:
- metadata write-through path を更新し、`PodMetadata.workspace_root` が永続化されるようにした。
- `PodMetadataStore::set_active_with_workspace_root(...)` を追加。
- 既存 `set_active(...)` は compatibility wrapper として残し、既存 workspace ownership を保持する挙動にした。
- `pod_metadata_writer_for_store(...)``metadata.workspace_root` を store に渡すようにした。
- persistence-boundary targeted test を追加。
- `metadata_writer_persists_workspace_root_through_store_update`
- `pod_metadata_writer_for_store` + `FsPodStore` を通し、stored metadata に runtime workspace root が残ることを検証。
Files changed in incremental fix:
- `crates/pod-store/src/lib.rs`
- `crates/pod/src/pod.rs`
Validation reported by Coder:
- `cargo test -p pod metadata_writer_persists_workspace_root_through_store_update -- --nocapture` — passed
- `cargo test -p pod pod_metadata -- --nocapture` — passed
- `cargo test -p pod-store` — passed
- `cargo test -p tui workspace_panel -- --nocapture` — passed
- `cargo test -p tui pod_list -- --nocapture` — passed
- `cargo check -p tui -p pod -p pod-store` — passed
- `cargo fmt --check` — passed
- `git diff --check` — passed
Orchestrator evidence checked:
- Pod output を確認。
- implementation worktree は clean。
- branch `impl/00001KVDH2E06-panel-current-workspace-pods` HEAD は `160c96ad`
- incremental diff stat from previous review target `3b634d66..160c96ad`: 2 files changed, 35 insertions, 2 deletions。
Routing classification: review_needed
Next action:
- previous `request_changes` blocker が解消されたか、read-only Reviewer Pod で再レビューする。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-18T15:29:46Z -->
## Plan
Review re-dispatch:
Reviewer Pod `yoi-reviewer-00001KVDH2E06-r2` を read-only scope で起動した。
Review target:
- worktree: `/home/hare/Projects/yoi/.worktree/00001KVDH2E06-panel-current-workspace-pods`
- branch: `impl/00001KVDH2E06-panel-current-workspace-pods`
- commits:
- `3b634d66` — initial implementation
- `160c96ad` — persistence-boundary fix
Reviewer focus:
- previous blocker が normal runtime/store write-through path で解消されているか。
- `set_active_with_workspace_root` が unrelated metadata fields を破壊せず、workspace ownership を永続化するか。
- compatibility `set_active` が existing workspace ownership を保持しているか。
- 新 test `metadata_writer_persists_workspace_root_through_store_update` が constructor ではなく `pod_metadata_writer_for_store` + `FsPodStore` boundary を実際に検証しているか。
- final branch が current workspace visible / external hidden / role visible / unknown corrupt fail-closed / hidden external action target なし、という Ticket acceptance criteria を満たすか。
Next:
- Reviewer output を確認し、approve なら Orchestrator worktree への merge/validation/cleanup/close-ready 処理へ進む。request_changes なら blocker を記録して Coder に戻す。
---
<!-- event: review author: yoi-reviewer-00001KVDH2E06-r2 at: 2026-06-18T15:37:52Z status: approve -->
## Review: approve
Review result: approve
対象:
- Ticket: `00001KVDH2E06`
- branch: `impl/00001KVDH2E06-panel-current-workspace-pods`
- commits:
- `3b634d66``tui: filter panel pods by workspace`
- `160c96ad``pod: persist metadata workspace root`
No blockers found.
Evidence reviewed:
- Previous blocker is fixed in the normal write-through path.
- `crates/pod/src/pod.rs:68-82``pod_metadata_writer_for_store(...)``store.set_active_with_workspace_root(...)` を呼び、`metadata.workspace_root` を渡している。
- `pod_metadata_for_manifest(...)` は引き続き `.with_workspace_root(workspace_root.to_path_buf())` で runtime metadata を構築しており、computed runtime workspace root が writer から `PodMetadataStore` へ流れる。
- `set_active_with_workspace_root` は unrelated metadata を保持している。
- `crates/pod-store/src/lib.rs:178-192``active`、`resolved_manifest_snapshot`、および `workspace_root``Some` の場合のみ `workspace_root` を更新する。
- `update_by_name` を使うため、`spawned_children`、`reclaimed_children`、`peers` などは保持される。
- compatibility `set_active` は existing workspace ownership を保持している。
- `crates/pod-store/src/lib.rs:168-176``workspace_root: None` で delegate する。
- delegated implementation は `if let Some(...)` の中でだけ `metadata.workspace_root` を書くため、legacy callers は既存 workspace ownership を clear しない。
- 新 test は constructor ではなく persistence boundary を検証している。
- `metadata_writer_persists_workspace_root_through_store_update` は real `FsPodStore` を作り、production `pod_metadata_writer_for_store` を取得し、writer を実行した後、stored metadata を読み戻して `workspace_root` が残ることを assert している。
- Final branch still satisfies Ticket behavior。
- `PodList::from_workspace_sources` は persisted `workspace_root` で stored Pods を filter し、live probes は stored current-workspace names に限定するため、live-only / legacy / unknown Pods は fail-closed。
- `WorkspacePanelViewModel` construction も `pods.filter_for_workspace(workspace_root)` を通すため、external workspace rows は normal Panel rows/action targets にならない。
- focused tests は current workspace visible、external/legacy/corrupt/live-only hidden、role-like current workspace Pods visible、normal open action only for visible rows を覆っている。
Reviewer validation:
- `git diff --check 3b634d66..160c96ad` — passed
- `git diff --check e2e76d3b..HEAD` — passed
- `cargo test -p pod metadata_writer_persists_workspace_root_through_store_update -- --nocapture` — passed
- `cargo test -p pod-store active_updates_preserve_children_and_manifest_snapshot -- --nocapture` — passed
- `cargo test -p tui pod_list -- --nocapture` — passed
- `cargo test -p tui workspace_panel_filters_pod_rows_to_current_workspace_metadata -- --nocapture` — passed
Known validation debt:
- Full `cargo test -p tui -- --nocapture` still fails in three tests, but these appear pre-existing/unrelated to this branch:
- `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`
- `spawn::tests::profile_choices_use_project_registry_default`
- `spawn::tests::profile_choices_include_builtin_and_project_default_marker`
- The failing orchestrator assertion/code is already identical on `develop`, and the spawn failures are in unrelated profile-choice tests.
Conclusion:
- Previous request_changes blocker is resolved.
- Implementation satisfies the recorded Ticket acceptance criteria and is ready for merge/validation in the Orchestrator worktree.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T15:39:28Z -->
## Implementation report
Merge / validation report:
Implementation branch `impl/00001KVDH2E06-panel-current-workspace-pods` を Orchestrator worktree の `orchestration` branch に merge した。
Merge:
- merge commit: `0ef36b4e``merge: panel workspace pod filter`
- implementation commits:
- `3b634d66``tui: filter panel pods by workspace`
- `160c96ad``pod: persist metadata workspace root`
- changed files:
- `crates/pod-store/src/lib.rs`
- `crates/pod/src/discovery.rs`
- `crates/pod/src/pod.rs`
- `crates/pod/src/ticket_event_notify.rs`
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/pod_list.rs`
- `crates/tui/src/workspace_panel.rs`
Validation in Orchestrator worktree:
- `cargo fmt --check` — passed
- `cargo check -p tui -p pod -p pod-store` — passed
- `cargo test -p pod metadata_writer_persists_workspace_root_through_store_update -- --nocapture` — passed
- `cargo test -p pod pod_metadata -- --nocapture` — passed
- `cargo test -p pod-store` — passed; 6 passed, 0 failed
- `cargo test -p tui workspace_panel -- --nocapture` — passed; 23 passed, 0 failed
- `cargo test -p tui pod_list -- --nocapture` — passed; 19 passed, 0 failed
- `git diff --check` — passed
Review:
- First review returned `request_changes` for missing persistence of `workspace_root` in the normal metadata write-through path.
- Coder fixed the persistence boundary and added a targeted `pod_metadata_writer_for_store` + `FsPodStore` test.
- Re-review `yoi-reviewer-00001KVDH2E06-r2` returned `approve` with no blockers.
Known validation debt:
- Full `cargo test -p tui` has three failures that were reproduced on the Orchestrator branch before this branch was merged and are treated as pre-existing/unrelated for this Ticket:
- `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`
- `spawn::tests::profile_choices_use_project_registry_default`
- `spawn::tests::profile_choices_include_builtin_and_project_default_marker`
Outcome:
- Normal Panel Pod rows/action targets are filtered to current workspace Pods using persisted runtime workspace metadata.
- External workspace Pods and unknown/corrupt/live-only metadata fail closed for normal rows.
- Current workspace role-like Pods remain visible when persisted runtime workspace matches, independent of cwd/worktree differences.
- Root/original workspace was not read/written/merged/validated for this Ticket, per Panel Queue instruction.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-18T15:40:02Z -->
## Implementation report
Cleanup report:
Panel current-workspace Pod filter Ticket の implementation/review Pods と child worktree を cleanup した。
Stopped Pods:
- `yoi-coder-00001KVDH2E06`
- `yoi-reviewer-00001KVDH2E06-r2`
Removed child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KVDH2E06-panel-current-workspace-pods`
Deleted merged implementation branch:
- `impl/00001KVDH2E06-panel-current-workspace-pods` at `160c96ad`
Orchestrator branch remains the integration authority for this completed work. Root/original workspace was not touched for this Ticket, per Panel Queue instruction.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-18T15:40:11Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
## State changed
Implementation commits `3b634d66` and `160c96ad` were reviewed after blocker resolution, approved, merged into the Orchestrator branch as `0ef36b4e`, validated in the Orchestrator worktree, and child implementation resources were cleaned up. Requirements and acceptance criteria are satisfied for this Ticket. Root/original workspace was not operated on for this Ticket per Panel Queue instruction.
---
<!-- event: state_changed author: hare at: 2026-06-18T15:40:38Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-18T15:40:38Z status: closed -->
## 完了
Ticket `00001KVDH2E06` is complete.
Completed implementation:
- Normal `yoi panel` Pod rows and Pod action targets are filtered to the current runtime workspace using persisted Pod metadata / runtime workspace ownership.
- Added optional persisted `workspace_root` to `PodMetadata` and ensured normal metadata write-through persists it.
- Added `PodMetadataStore::set_active_with_workspace_root(...)` while keeping compatibility `set_active(...)` preserving existing workspace ownership.
- Workspace-external Pods are excluded from normal Panel rows/action targets.
- Unknown/corrupt/live-only metadata fail closed and are not treated as current workspace Pods.
- Current-workspace role-like Pods remain visible when persisted runtime workspace matches, independent of cwd/worktree differences.
Reviewed / merged:
- Initial implementation: `3b634d66` (`tui: filter panel pods by workspace`)
- Persistence-boundary fix: `160c96ad` (`pod: persist metadata workspace root`)
- First review requested changes for missing persistence of `workspace_root` through the normal store writer path.
- Re-review approved with no remaining blockers.
- Orchestrator merge commit: `0ef36b4e` (`merge: panel workspace pod filter`)
Validation in Orchestrator worktree:
- `cargo fmt --check` — passed
- `cargo check -p tui -p pod -p pod-store` — passed
- `cargo test -p pod metadata_writer_persists_workspace_root_through_store_update -- --nocapture` — passed
- `cargo test -p pod pod_metadata -- --nocapture` — passed
- `cargo test -p pod-store` — passed; 6 passed, 0 failed
- `cargo test -p tui workspace_panel -- --nocapture` — passed; 23 passed, 0 failed
- `cargo test -p tui pod_list -- --nocapture` — passed; 19 passed, 0 failed
- `git diff --check` — passed
Known validation debt:
- Full `cargo test -p tui` still has three pre-existing/unrelated failures reproduced on the Orchestrator branch before this implementation was merged:
- `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`
- `spawn::tests::profile_choices_use_project_registry_default`
- `spawn::tests::profile_choices_include_builtin_and_project_default_marker`
Cleanup:
- Stopped Coder Pod `yoi-coder-00001KVDH2E06`.
- Stopped Reviewer Pod `yoi-reviewer-00001KVDH2E06-r2`.
- Removed child worktree `/home/hare/Projects/yoi/.worktree/00001KVDH2E06-panel-current-workspace-pods`.
- Deleted merged branch `impl/00001KVDH2E06-panel-current-workspace-pods`.
Root/original workspace was not read/written/merged/validated for this Ticket, per Panel Queue instruction. The completed work is integrated on the Orchestrator branch.
---

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVDJCVWZ",
"kind": "related",
"target": "00001KTTW04W2",
"note": "Fixes live delivery gap for auto_run:false Orchestrator Ticket event Companion notifications.",
"author": "yoi ticket",
"at": "2026-06-18T14:33:50Z"
}
]
}

View File

@ -0,0 +1,70 @@
---
title: 'Orchestrator Ticket event Companion notify の peer registration / diagnostics を修正する'
state: 'done'
created_at: '2026-06-18T14:33:09Z'
updated_at: '2026-06-18T14:33:50Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['orchestrator', 'companion', 'peer-notify', 'ticket-event', 'auto-run-false', 'diagnostics']
---
## Background
`00001KTTW04W2` で Orchestrator role の明示 Ticket lifecycle event を workspace Companion に `Notify { auto_run: false }` で送る hook は実装済みだった。しかし live 状態では Orchestrator metadata 側に workspace Companion peer が無いと、`send_weak_notify_to_live_peer` が silent no-op になり、Companion に通知が届かない。
現在の運用では workspace Companion `yoi``yoi-orchestrator` が同じ workspace の role Pod として存在していても、peer metadata が片方向または欠落することがある。この場合、通知 hook は実行されても delivery 前提を満たせず、ユーザーには何も見えない。
この Ticket では peer 境界を緩めず、workspace Companion / Orchestrator の reciprocal peer relationship を通知前に保証し、delivery skip reason を bounded diagnostic として残す。
## Requirements
- Orchestrator Ticket event Companion notify は `auto_run: false` を維持する。
- Companion missing / stopped / unreachable では spawn / restore しない。
- Peer visibility check は維持する。
- arbitrary Pod name へ notify できるようにはしない。
- Orchestrator startup / hook install 時に、既存 workspace Companion metadata があれば reciprocal peer を ensure する。
- Ticket event hook 実行時にも、既存 workspace Companion metadata があれば reciprocal peer を ensure する。
- 既存 running state で片方向 peer / missing peer があっても回復できるようにする。
- `send_weak_notify_to_live_peer` は bool ではなく delivery reason を返す。
- delivered
- missing metadata
- not visible
- visible but not peer
- not live / unreachable
- send failed
- Delivery skip / failure reason は bounded tracing diagnostic として確認できる。
- Missing Companion は debug/no-op に留める。
- Ticket event hook は passive Ticket read/list/show では発火しない既存条件を維持する。
## Implementation summary
- `PodDiscovery::ensure_existing_peer` を追加し、peer metadata が存在する場合だけ reciprocal peer registration を行う。
- `register_peer``ensure_existing_peer` を使う形に整理し、missing peer は従来どおり `MissingPod` error を返す。
- `WeakNotifyDelivery` を追加し、weak notify delivery result を reason 付きで返すようにした。
- Orchestrator Ticket event hook install 時に existing Companion peer を ensure する。
- Ticket event hook call 時にも existing Companion peer を ensure してから weak notify する。
- Delivery skipped / send failed を `warn!`、missing Companion / ensured peer を `debug!` で記録する。
- Tests を追加・更新し、peer が事前登録されていない既存 Companion metadata でも hook が reciprocal peer を作り、`Notify { auto_run: false }` を届けることを確認した。
## Acceptance criteria
- Existing workspace Companion metadata がある場合、Orchestrator Ticket event notify 前に reciprocal peer が ensure される。
- Orchestrator Ticket event hook は peer 未登録の既存 Companion に `Notify { auto_run: false }` を届けられる。
- `send_weak_notify_to_live_peer` は delivered / skipped reason を区別して返す。
- Spawned-child visibility など peer ではない Pod には weak notify しない。
- Missing Companion では spawn / restore せず no-op diagnostic に留める。
- Passive Ticket tool call では通知しない既存挙動を維持する。
## Validation
- `cargo test -p pod discovery::tests::register_peer_persists_reciprocal_metadata --no-default-features`
- `cargo test -p pod weak_notify --no-default-features`
- `cargo test -p pod ticket_event_notify --no-default-features`
- `cargo check -p pod -p tui --all-targets`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi --no-link`
## Related work
- `00001KTTW04W2` — Orchestrator進捗をAutoKickなしでCompanionへ通知する。

View File

@ -0,0 +1,7 @@
<!-- event: create author: "yoi ticket" at: 2026-06-18T14:33:09Z -->
## 作成
LocalTicketBackend によって作成されました。
---

129
Cargo.lock generated
View File

@ -1633,6 +1633,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.16" version = "0.1.16"
@ -2360,6 +2366,8 @@ dependencies = [
"tools", "tools",
"tracing", "tracing",
"uuid", "uuid",
"wasmi",
"wat",
"workflow", "workflow",
] ]
@ -3342,6 +3350,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -3354,6 +3368,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.8.9" version = "0.8.9"
@ -4229,7 +4253,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [ dependencies = [
"leb128fmt", "leb128fmt",
"wasmparser", "wasmparser 0.244.0",
]
[[package]]
name = "wasm-encoder"
version = "0.246.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7"
dependencies = [
"leb128fmt",
"wasmparser 0.246.2",
] ]
[[package]] [[package]]
@ -4240,8 +4274,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap", "indexmap",
"wasm-encoder", "wasm-encoder 0.244.0",
"wasmparser", "wasmparser 0.244.0",
] ]
[[package]] [[package]]
@ -4257,6 +4291,56 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "wasmi"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
]
[[package]]
name = "wasmi_collections"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9b8e98e45a2a534489f8225e765cbf1cb9a3078072605e58158910cf4749172"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c25f375c0cdf14810eab07f532f61f14d4966f09c747a55067fdf3196e8512e6"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "0.51.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624e2a68a4293ecb8f564260b68394b29cf3b3edba6bce35532889a2cb33c3d9"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.11.0",
"indexmap",
]
[[package]] [[package]]
name = "wasmparser" name = "wasmparser"
version = "0.244.0" version = "0.244.0"
@ -4269,6 +4353,39 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "wasmparser"
version = "0.246.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d"
dependencies = [
"bitflags 2.11.0",
"indexmap",
"semver",
]
[[package]]
name = "wast"
version = "246.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder 0.246.2",
]
[[package]]
name = "wat"
version = "1.246.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c"
dependencies = [
"wast",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.94" version = "0.3.94"
@ -4730,9 +4847,9 @@ dependencies = [
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"wasm-encoder", "wasm-encoder 0.244.0",
"wasm-metadata", "wasm-metadata",
"wasmparser", "wasmparser 0.244.0",
"wit-parser", "wit-parser",
] ]
@ -4751,7 +4868,7 @@ dependencies = [
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"unicode-xid", "unicode-xid",
"wasmparser", "wasmparser 0.244.0",
] ]
[[package]] [[package]]

View File

@ -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)
@ -637,6 +743,146 @@ pub fn resolve_plugin_config_for_startup(
snapshot snapshot
} }
/// Load the recorded WASM runtime module for a resolved plugin package.
///
/// Restore and execution paths use this helper instead of reading arbitrary
/// package paths directly so module selection remains tied to the resolved
/// package identity, runtime manifest entry, and deterministic package digest.
pub fn read_resolved_plugin_runtime_module(
record: &ResolvedPluginRecord,
limits: &PluginDiscoveryLimits,
) -> Result<Vec<u8>, PluginDiagnostic> {
let runtime = record.manifest.runtime.as_ref().ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Missing,
PluginDiagnosticPhase::Manifest,
"resolved plugin package does not declare a WASM runtime",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
if runtime.kind != "wasm" {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Api,
PluginDiagnosticPhase::Manifest,
"plugin runtime kind is unsupported",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
if runtime.abi.as_deref() != Some("yoi-plugin-wasm-1") {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Api,
PluginDiagnosticPhase::Manifest,
"plugin WASM ABI is unsupported",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
let metadata = fs::metadata(&record.package_path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"resolved plugin package metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
if !metadata.is_file() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Discovery,
"resolved plugin package is not a regular file",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
if metadata.len() > limits.max_package_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"resolved plugin package exceeds the configured package size bound",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest));
}
let bytes = fs::read(&record.package_path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"resolved plugin package content could not be read: {}",
safe_io_error(&error)
),
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
let archive = parse_stored_zip(&bytes, &record.package_label, record.source, limits)?;
let actual_digest = deterministic_digest(&archive.files);
if !digest_matches(&record.digest, &actual_digest) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Digest,
PluginDiagnosticPhase::Resolution,
"resolved plugin package digest does not match current package content",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(actual_digest));
}
validate_manifest_path(
&runtime.entry,
&archive,
&record.package_label,
record.source,
&record.manifest.id,
)?;
let normalized = normalize_archive_path(&runtime.entry).ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Manifest,
"plugin manifest references a path outside the package root",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})?;
archive.files.get(&normalized).cloned().ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Missing,
PluginDiagnosticPhase::Manifest,
"plugin runtime module entry is missing from the package",
)
.with_source(record.source)
.with_identity(&record.identity)
.with_package(&record.package_label)
.with_digest(&record.digest)
})
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct PluginStore { struct PluginStore {
source: PluginSourceKind, source: PluginSourceKind,
@ -1797,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);
@ -1811,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()

View File

@ -100,6 +100,8 @@ pub struct PodMetadata {
pub pod_name: String, pub pod_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<PodActiveSegmentRef>, pub active: Option<PodActiveSegmentRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_children: Vec<PodSpawnedChild>, pub spawned_children: Vec<PodSpawnedChild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@ -116,12 +118,18 @@ impl PodMetadata {
Self { Self {
pod_name: pod_name.into(), pod_name: pod_name.into(),
active, active,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
resolved_manifest_snapshot: None, resolved_manifest_snapshot: None,
} }
} }
pub fn with_workspace_root(mut self, workspace_root: PathBuf) -> Self {
self.workspace_root = Some(workspace_root);
self
}
} }
/// Sync persistence backend for Pod metadata. /// Sync persistence backend for Pod metadata.
@ -157,16 +165,30 @@ pub trait PodMetadataStore: Send + Sync {
Ok(metadata) Ok(metadata)
} }
/// Set the active pointer while preserving spawned children and manifest snapshot. /// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
fn set_active( fn set_active(
&self, &self,
pod_name: &str, pod_name: &str,
active: Option<PodActiveSegmentRef>, active: Option<PodActiveSegmentRef>,
resolved_manifest_snapshot: Option<serde_json::Value>, resolved_manifest_snapshot: Option<serde_json::Value>,
) -> Result<PodMetadata, PodStoreError> {
self.set_active_with_workspace_root(pod_name, active, resolved_manifest_snapshot, None)
}
/// Set the active pointer and workspace ownership while preserving unrelated fields.
fn set_active_with_workspace_root(
&self,
pod_name: &str,
active: Option<PodActiveSegmentRef>,
resolved_manifest_snapshot: Option<serde_json::Value>,
workspace_root: Option<PathBuf>,
) -> Result<PodMetadata, PodStoreError> { ) -> Result<PodMetadata, PodStoreError> {
self.update_by_name(pod_name, |metadata| { self.update_by_name(pod_name, |metadata| {
metadata.active = active; metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
if let Some(workspace_root) = workspace_root {
metadata.workspace_root = Some(workspace_root);
}
}) })
} }

View File

@ -35,11 +35,13 @@ workflow-crate = { package = "workflow", path = "../workflow" }
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true } session-metrics = { workspace = true }
arc-swap = "1.9.1" arc-swap = "1.9.1"
wasmi = { version = "0.51.1", default-features = false, features = ["std", "extra-checks"] }
[dev-dependencies] [dev-dependencies]
dotenv = "0.15.0" dotenv = "0.15.0"
futures = { workspace = true } futures = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
wat = "1.241.2"
[build-dependencies] [build-dependencies]
toml = { workspace = true } toml = { workspace = true }

View File

@ -10,6 +10,7 @@ use session_store::Store;
use ticket::LocalTicketBackend; use ticket::LocalTicketBackend;
use ticket::config::TicketConfig; use ticket::config::TicketConfig;
use tokio::sync::{broadcast, mpsc, oneshot}; use tokio::sync::{broadcast, mpsc, oneshot};
use tracing::{debug, warn};
use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool}; use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool};
use crate::feature::FeatureRegistryBuilder; use crate::feature::FeatureRegistryBuilder;
@ -546,6 +547,30 @@ fn install_ticket_event_companion_notify_hook<C, St>(
pod.cwd().to_path_buf(), pod.cwd().to_path_buf(),
spawned_registry, spawned_registry,
); );
match discovery.ensure_existing_peer(&companion_pod_name) {
Ok(Some(_)) => {
debug!(
companion = %companion_pod_name,
orchestrator = %pod.manifest().pod.name,
"ensured Companion peer relationship for Orchestrator Ticket event notifications"
);
}
Ok(None) => {
debug!(
companion = %companion_pod_name,
orchestrator = %pod.manifest().pod.name,
"Companion metadata is missing; Ticket event notifications will skip until Companion exists"
);
}
Err(error) => {
warn!(
companion = %companion_pod_name,
orchestrator = %pod.manifest().pod.name,
error = %error,
"failed to ensure Companion peer relationship for Orchestrator Ticket event notifications"
);
}
}
pod.add_post_tool_call_hook(TicketEventCompanionNotifyHook::new( pod.add_post_tool_call_hook(TicketEventCompanionNotifyHook::new(
LocalTicketBackend::new(backend_root), LocalTicketBackend::new(backend_root),
discovery, discovery,

View File

@ -7,6 +7,7 @@
//! state that exists but is outside that visibility set. //! state that exists but is outside that visibility set.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
@ -177,6 +178,16 @@ where
&self, &self,
peer_name: &str, peer_name: &str,
) -> Result<PeerRegistrationResult, PodDiscoveryError> { ) -> Result<PeerRegistrationResult, PodDiscoveryError> {
self.ensure_existing_peer(peer_name)?
.ok_or_else(|| PodDiscoveryError::MissingPod {
pod_name: peer_name.to_string(),
})
}
pub fn ensure_existing_peer(
&self,
peer_name: &str,
) -> Result<Option<PeerRegistrationResult>, PodDiscoveryError> {
validate_pod_name(peer_name)?; validate_pod_name(peer_name)?;
if peer_name == self.self_pod_name { if peer_name == self.self_pod_name {
return Err(PodDiscoveryError::SelfPeer { return Err(PodDiscoveryError::SelfPeer {
@ -191,9 +202,7 @@ where
})?; })?;
let prior_self_peers = self_metadata.peers.clone(); let prior_self_peers = self_metadata.peers.clone();
if self.store.read_by_name(peer_name)?.is_none() { if self.store.read_by_name(peer_name)?.is_none() {
return Err(PodDiscoveryError::MissingPod { return Ok(None);
pod_name: peer_name.to_string(),
});
} }
self.store.add_peer(&self.self_pod_name, peer_name)?; self.store.add_peer(&self.self_pod_name, peer_name)?;
@ -202,10 +211,10 @@ where
return Err(PodDiscoveryError::PodStore(error)); return Err(PodDiscoveryError::PodStore(error));
} }
Ok(PeerRegistrationResult { Ok(Some(PeerRegistrationResult {
source: self.self_pod_name.clone(), source: self.self_pod_name.clone(),
peer: peer_name.to_string(), peer: peer_name.to_string(),
}) }))
} }
async fn visibility(&self) -> Result<VisibilitySet, PodDiscoveryError> { async fn visibility(&self) -> Result<VisibilitySet, PodDiscoveryError> {
@ -354,16 +363,41 @@ where
} }
} }
pub async fn send_weak_notify_to_live_peer(&self, peer_name: &str, message: String) -> bool { pub async fn send_weak_notify_to_live_peer(
let Ok(detail) = self.inspect(peer_name).await else { &self,
return false; peer_name: &str,
message: String,
) -> WeakNotifyDelivery {
let detail = match self.inspect(peer_name).await {
Ok(detail) => detail,
Err(PodDiscoveryError::StateMissing { .. } | PodDiscoveryError::MissingPod { .. }) => {
return WeakNotifyDelivery::SkippedMissing;
}
Err(PodDiscoveryError::NotVisible { .. }) => {
return WeakNotifyDelivery::SkippedNotVisible;
}
Err(error) => {
return WeakNotifyDelivery::SendFailed {
error: error.to_string(),
};
}
}; };
if detail.visibility != VisibilityReason::Peer || !detail.live.reachable { if detail.visibility != VisibilityReason::Peer {
return false; return WeakNotifyDelivery::SkippedNotPeer {
visibility: detail.visibility,
};
}
if !detail.live.reachable {
return WeakNotifyDelivery::SkippedNotLive {
reason: detail.live.error,
};
}
match send_notify(&detail.live.socket_path, message, false).await {
Ok(()) => WeakNotifyDelivery::Delivered,
Err(error) => WeakNotifyDelivery::SendFailed {
error: error.to_string(),
},
} }
send_notify(&detail.live.socket_path, message, false)
.await
.is_ok()
} }
async fn live_for_name(&self, pod_name: &str, socket_override: Option<&Path>) -> LiveInfo { async fn live_for_name(&self, pod_name: &str, socket_override: Option<&Path>) -> LiveInfo {
@ -585,6 +619,50 @@ pub struct PeerRegistrationResult {
pub peer: String, pub peer: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WeakNotifyDelivery {
Delivered,
SkippedMissing,
SkippedNotVisible,
SkippedNotPeer { visibility: VisibilityReason },
SkippedNotLive { reason: Option<String> },
SendFailed { error: String },
}
impl WeakNotifyDelivery {
pub fn delivered(&self) -> bool {
matches!(self, WeakNotifyDelivery::Delivered)
}
}
impl fmt::Display for WeakNotifyDelivery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WeakNotifyDelivery::Delivered => write!(f, "delivered"),
WeakNotifyDelivery::SkippedMissing => {
write!(f, "skipped: target pod metadata is missing")
}
WeakNotifyDelivery::SkippedNotVisible => {
write!(f, "skipped: target pod is not visible")
}
WeakNotifyDelivery::SkippedNotPeer { visibility } => {
write!(
f,
"skipped: target pod is visible as {visibility:?}, not peer"
)
}
WeakNotifyDelivery::SkippedNotLive { reason } => {
if let Some(reason) = reason {
write!(f, "skipped: target peer is not live/reachable ({reason})")
} else {
write!(f, "skipped: target peer is not live/reachable")
}
}
WeakNotifyDelivery::SendFailed { error } => write!(f, "send failed: {error}"),
}
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum PodDiscoveryError { pub enum PodDiscoveryError {
#[error("pod state missing for `{pod_name}`")] #[error("pod state missing for `{pod_name}`")]
@ -1030,6 +1108,7 @@ mod tests {
let parent = PodMetadata { let parent = PodMetadata {
pod_name: "parent".into(), pod_name: "parent".into(),
active: None, active: None,
workspace_root: None,
spawned_children: vec![ spawned_children: vec![
child("child-live", &live_socket), child("child-live", &live_socket),
child("child-stale", &stale_socket), child("child-stale", &stale_socket),
@ -1049,6 +1128,7 @@ mod tests {
session_id, session_id,
active_child_segment, active_child_segment,
)), )),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1062,6 +1142,7 @@ mod tests {
session_id, session_id,
active_child_segment, active_child_segment,
)), )),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1072,6 +1153,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "child-pending".into(), pod_name: "child-pending".into(),
active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)), active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1085,6 +1167,7 @@ mod tests {
session_id, session_id,
new_segment_id(), new_segment_id(),
)), )),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1095,6 +1178,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "peer".into(), pod_name: "peer".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1288,6 +1372,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1327,6 +1412,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1339,6 +1425,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "target".into(), pod_name: "target".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1441,6 +1528,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1453,6 +1541,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "target".into(), pod_name: "target".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1524,18 +1613,20 @@ mod tests {
} }
}); });
assert!( assert_eq!(
discovery discovery
.send_weak_notify_to_live_peer("target", "weak event".into()) .send_weak_notify_to_live_peer("target", "weak event".into())
.await .await,
WeakNotifyDelivery::Delivered
); );
assert_eq!(rx.recv().await.unwrap(), "weak event"); assert_eq!(rx.recv().await.unwrap(), "weak event");
target.await.unwrap(); target.await.unwrap();
assert!( assert_eq!(
!discovery discovery
.send_weak_notify_to_live_peer("missing", "no-op".into()) .send_weak_notify_to_live_peer("missing", "no-op".into())
.await .await,
WeakNotifyDelivery::SkippedMissing
); );
} }
@ -1551,6 +1642,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: vec![child("target", &socket)], spawned_children: vec![child("target", &socket)],
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1567,10 +1659,13 @@ mod tests {
SpawnedPodRegistry::new(runtime_dir), SpawnedPodRegistry::new(runtime_dir),
); );
assert!( assert_eq!(
!discovery discovery
.send_weak_notify_to_live_peer("target", "must not send".into()) .send_weak_notify_to_live_peer("target", "must not send".into())
.await .await,
WeakNotifyDelivery::SkippedNotPeer {
visibility: VisibilityReason::SpawnedChild
}
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -72,10 +72,11 @@ where
let store = store.clone(); let store = store.clone();
Arc::new(move |metadata| { Arc::new(move |metadata| {
store store
.set_active( .set_active_with_workspace_root(
&metadata.pod_name, &metadata.pod_name,
metadata.active, metadata.active,
metadata.resolved_manifest_snapshot, metadata.resolved_manifest_snapshot,
metadata.workspace_root,
) )
.map(|_| ()) .map(|_| ())
}) })
@ -924,7 +925,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} }
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata { fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata {
pod_metadata_for_manifest(&self.manifest, active) pod_metadata_for_manifest(&self.manifest, &self.workspace_root, active)
} }
fn write_pod_metadata_pending(&self) -> Result<(), PodError> { fn write_pod_metadata_pending(&self) -> Result<(), PodError> {
@ -4319,9 +4320,11 @@ fn request_config_from_worker_manifest(wm: &WorkerManifest) -> RequestConfig {
fn pod_metadata_for_manifest( fn pod_metadata_for_manifest(
manifest: &PodManifest, manifest: &PodManifest,
workspace_root: &Path,
active: Option<PodActiveSegmentRef>, active: Option<PodActiveSegmentRef>,
) -> PodMetadata { ) -> PodMetadata {
let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active); let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active)
.with_workspace_root(workspace_root.to_path_buf());
if should_persist_resolved_manifest_snapshot(manifest) { if should_persist_resolved_manifest_snapshot(manifest) {
metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok(); metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok();
} }
@ -5234,6 +5237,24 @@ permission = "write"
mod pod_metadata_restore_manifest_tests { mod pod_metadata_restore_manifest_tests {
use super::*; use super::*;
#[test]
fn metadata_writer_persists_workspace_root_through_store_update() {
let temp = tempfile::tempdir().unwrap();
let store = pod_store::FsPodStore::new(temp.path().join("pods")).unwrap();
let workspace_root = temp.path().join("workspace-root");
std::fs::create_dir_all(&workspace_root).unwrap();
let writer = pod_metadata_writer_for_store(&store);
writer(PodMetadata::new("runtime-pod", None).with_workspace_root(workspace_root.clone()))
.unwrap();
let stored = store.read_by_name("runtime-pod").unwrap().unwrap();
assert_eq!(
stored.workspace_root.as_deref(),
Some(workspace_root.as_path())
);
}
#[test] #[test]
fn snapshot_preserves_saved_scope_over_current_manifest() { fn snapshot_preserves_saved_scope_over_current_manifest() {
let saved = PodManifest::from_toml( let saved = PodManifest::from_toml(
@ -5328,7 +5349,7 @@ permission = "read"
.unwrap(); .unwrap();
assert!(manifest.profile.is_none()); assert!(manifest.profile.is_none());
assert!( assert!(
pod_metadata_for_manifest(&manifest, None) pod_metadata_for_manifest(&manifest, Path::new("/snapshot/workspace"), None)
.resolved_manifest_snapshot .resolved_manifest_snapshot
.is_none() .is_none()
); );
@ -5354,13 +5375,14 @@ 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(),
config: None, config: None,
}]; }];
let metadata = pod_metadata_for_manifest(&manifest, None); let metadata = pod_metadata_for_manifest(&manifest, Path::new("/snapshot/workspace"), None);
let snapshot = metadata let snapshot = metadata
.resolved_manifest_snapshot .resolved_manifest_snapshot
.expect("plugin-resolved manifest should be snapshotted"); .expect("plugin-resolved manifest should be snapshotted");

View File

@ -5,9 +5,9 @@ use minijinja::Value as TemplateValue;
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug}; use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug};
use tracing::debug; use tracing::{debug, warn};
use crate::discovery::PodDiscovery; use crate::discovery::{PodDiscovery, WeakNotifyDelivery};
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary}; use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
use crate::prompt::catalog::{PodPrompt, PromptCatalog}; use crate::prompt::catalog::{PodPrompt, PromptCatalog};
use pod_store::PodMetadataStore; use pod_store::PodMetadataStore;
@ -48,17 +48,58 @@ impl<St: PodMetadataStore + Clone + Send + Sync + 'static> Hook<PostToolCall>
let Some(notice) = build_ticket_event_notice(&self.backend, summary) else { let Some(notice) = build_ticket_event_notice(&self.backend, summary) else {
return HookPostToolAction::Continue; return HookPostToolAction::Continue;
}; };
let delivered = self match self
.discovery
.ensure_existing_peer(&self.companion_pod_name)
{
Ok(Some(_)) => {
debug!(
ticket = %notice.ticket_id,
event_kind = %notice.event_kind,
companion = %self.companion_pod_name,
"ensured Companion peer relationship before Ticket event notification"
);
}
Ok(None) => {
debug!(
ticket = %notice.ticket_id,
event_kind = %notice.event_kind,
companion = %self.companion_pod_name,
"skipping Companion peer registration because Companion metadata is missing"
);
}
Err(error) => {
warn!(
ticket = %notice.ticket_id,
event_kind = %notice.event_kind,
companion = %self.companion_pod_name,
error = %error,
"failed to ensure Companion peer relationship before Ticket event notification"
);
}
}
let delivery = self
.discovery .discovery
.send_weak_notify_to_live_peer(&self.companion_pod_name, notice.message) .send_weak_notify_to_live_peer(&self.companion_pod_name, notice.message)
.await; .await;
if delivered { match delivery {
debug!( WeakNotifyDelivery::Delivered => {
ticket = %notice.ticket_id, debug!(
event_kind = %notice.event_kind, ticket = %notice.ticket_id,
companion = %self.companion_pod_name, event_kind = %notice.event_kind,
"delivered weak Ticket event notification to Companion peer" companion = %self.companion_pod_name,
); "delivered weak Ticket event notification to Companion peer"
);
}
skipped => {
warn!(
ticket = %notice.ticket_id,
event_kind = %notice.event_kind,
companion = %self.companion_pod_name,
delivery = %skipped,
"skipped weak Ticket event notification to Companion peer"
);
}
} }
HookPostToolAction::Continue HookPostToolAction::Continue
} }
@ -327,7 +368,7 @@ mod tests {
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn ticket_event_hook_delivers_weak_companion_notification() { async fn ticket_event_hook_ensures_peer_and_delivers_weak_companion_notification() {
let root = tempdir().expect("tempdir"); let root = tempdir().expect("tempdir");
let runtime_base = root.path().join("runtime"); let runtime_base = root.path().join("runtime");
let store_dir = root.path().join("store"); let store_dir = root.path().join("store");
@ -337,11 +378,10 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "orchestrator".into(), pod_name: "orchestrator".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: Vec::new(),
pod_name: "companion".into(),
}],
resolved_manifest_snapshot: None, resolved_manifest_snapshot: None,
}) })
.unwrap(); .unwrap();
@ -349,11 +389,10 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "companion".into(), pod_name: "companion".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: Vec::new(),
pod_name: "orchestrator".into(),
}],
resolved_manifest_snapshot: None, resolved_manifest_snapshot: None,
}) })
.unwrap(); .unwrap();
@ -363,6 +402,7 @@ mod tests {
.await .await
.unwrap(), .unwrap(),
); );
let store_for_assert = store.clone();
let hook = TicketEventCompanionNotifyHook::new( let hook = TicketEventCompanionNotifyHook::new(
backend, backend,
PodDiscovery::new( PodDiscovery::new(
@ -448,6 +488,15 @@ mod tests {
let message = rx.recv().await.unwrap(); let message = rx.recv().await.unwrap();
assert!(message.contains("event: state/queued->inprogress")); assert!(message.contains("event: state/queued->inprogress"));
assert!(message.contains("title: Companion event hook")); assert!(message.contains("title: Companion event hook"));
let orchestrator = store_for_assert
.read_by_name("orchestrator")
.unwrap()
.unwrap();
assert_eq!(orchestrator.peers.len(), 1);
assert_eq!(orchestrator.peers[0].pod_name, "companion");
let companion_metadata = store_for_assert.read_by_name("companion").unwrap().unwrap();
assert_eq!(companion_metadata.peers.len(), 1);
assert_eq!(companion_metadata.peers[0].pod_name, "orchestrator");
companion.await.unwrap(); companion.await.unwrap();
} }
} }

View File

@ -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;
@ -151,9 +155,13 @@ pub(crate) async fn run(
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
{ {
if !emitted_panel_ready { if !emitted_panel_ready {
// `panel_ready` is a first-visible-frame signal only. E2E tests that need
// list/data readiness must wait for a concrete `rows_rendered` fixture row.
crate::e2e_observer::emit("panel", "panel_ready", serde_json::json!({})); crate::e2e_observer::emit("panel", "panel_ready", serde_json::json!({}));
emitted_panel_ready = true; emitted_panel_ready = true;
} }
// Emit every drawn row snapshot separately so tests can assert data-backed row
// readiness without conflating it with the first frame.
app.emit_rows_rendered(); app.emit_rows_rendered();
} }
@ -921,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,
@ -937,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 {
@ -988,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,
@ -1006,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 {
@ -1042,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,
} }
} }
@ -1351,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,
row.title.clone(), status,
Some(row.status.clone()), action,
row.next_action.map(NextUserAction::label), disabled_reason,
), local_state,
overlay_state,
overlay_detail,
) = match panel_row {
Some(row) => {
let ticket = row.ticket.as_ref();
(
row.title.clone(),
Some(row.status.clone()),
row.next_action.map(NextUserAction::label),
row.disabled_reason.clone(),
ticket.map(|ticket| ticket.workflow_state.as_str().to_string()),
ticket
.and_then(|ticket| ticket.orchestration_overlay.as_ref())
.map(|overlay| overlay.workflow_state.as_str().to_string()),
ticket
.and_then(|ticket| ticket.orchestration_overlay.as_ref())
.map(|overlay| {
format!(
"{}:{}",
overlay.source,
overlay.workflow_state.as_str()
)
}),
)
}
None => match &hit.key { 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)
} }
}, },
}; };
@ -1378,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) {
@ -2282,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(
@ -2302,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(
@ -2328,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 })
} }
@ -3094,12 +3398,13 @@ async fn load_pod_list(
let live = read_reachable_live_pod_infos(&store) let live = read_reachable_live_pod_infos(&store)
.await .await
.unwrap_or_default(); .unwrap_or_default();
Ok(PodList::from_sources( Ok(PodList::from_workspace_sources(
PodVisibilitySource::ResumePicker, PodVisibilitySource::ResumePicker,
stored, stored,
live, live,
selected_name, selected_name,
max_entries, max_entries,
&current_workspace_root(),
)) ))
} }
@ -5402,6 +5707,9 @@ fn panel_ticket_detail(row: &PanelRow) -> String {
} }
let mut parts = vec![panel_ticket_reference(row)]; let mut parts = vec![panel_ticket_reference(row)];
if let Some(overlay_detail) = panel_ticket_overlay_detail(row) {
parts.push(overlay_detail);
}
if let Some(blocked_reason) = row if let Some(blocked_reason) = row
.ticket .ticket
.as_ref() .as_ref()
@ -5437,6 +5745,24 @@ fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static
} }
} }
fn panel_ticket_overlay_detail(row: &PanelRow) -> Option<String> {
let ticket = row.ticket.as_ref()?;
let overlay = ticket.orchestration_overlay.as_ref()?;
let mut detail = format!(
"Overlay: local {} · {} {}",
ticket.workflow_state.as_str(),
overlay.source,
overlay.workflow_state.as_str()
);
if matches!(
overlay.workflow_state,
TicketWorkflowState::Done | TicketWorkflowState::Closed
) {
detail.push_str(" · merge pending");
}
Some(detail)
}
fn panel_ticket_reason(row: &PanelRow) -> Option<&str> { fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
row.disabled_reason row.disabled_reason
.as_deref() .as_deref()
@ -7592,6 +7918,43 @@ branch = "orchestration/custom-panel"
assert!(detail_line.ends_with('…')); assert!(detail_line.ends_with('…'));
} }
#[test]
fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
let mut row = panel_test_ticket_row(
"00001OVERLAY",
"Overlay column regression",
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.kind = PanelRowKind::Review;
row.status = "q→done".to_string();
row.disabled_reason = Some(
"orchestration worktree overlay shows Ticket state done; local state remains queued"
.to_string(),
);
row.ticket.as_mut().unwrap().orchestration_overlay =
Some(crate::workspace_panel::TicketStateOverlay {
source: "orchestration".to_string(),
workflow_state: TicketWorkflowState::Done,
});
let lines = panel_row_lines(&row, false, 160);
let title_line = plain_line(&lines[0]);
let detail_line = plain_line(&lines[1]);
let state_start = 2;
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
assert!(row.status.width() <= TICKET_STATE_COLUMN_WIDTH);
assert_eq!(display_column(&title_line, "q→done"), state_start);
assert_eq!(
display_column(&title_line, "Overlay column regression"),
title_start
);
assert!(!title_line.contains("orchestration"));
assert!(detail_line.contains("Overlay: local queued · orchestration done · merge pending"));
}
#[test] #[test]
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() { fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
let mut row = panel_test_ticket_row( let mut row = panel_test_ticket_row(
@ -9028,6 +9391,7 @@ branch = "orchestration/custom-panel"
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
updated_at, updated_at,
workspace_root: None,
preview: None, preview: None,
} }
} }

View File

@ -1,4 +1,4 @@
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
@ -65,6 +65,56 @@ impl PodList {
} }
} }
pub(crate) fn from_workspace_sources(
source: PodVisibilitySource,
stored: Vec<StoredPodInfo>,
live: Vec<LivePodInfo>,
selected_name: Option<String>,
max_entries: usize,
workspace_root: &Path,
) -> Self {
let current_workspace = workspace_root_key(workspace_root);
let mut current_names = BTreeSet::new();
let stored: Vec<_> = stored
.into_iter()
.filter(|info| {
let matches = info
.workspace_root
.as_deref()
.is_some_and(|root| workspace_root_key(root) == current_workspace);
if matches {
current_names.insert(info.pod_name.clone());
}
matches
})
.collect();
let live = live
.into_iter()
.filter(|info| current_names.contains(&info.pod_name))
.collect();
Self::from_sources(source, stored, live, selected_name, max_entries)
}
pub(crate) fn filter_for_workspace(&self, workspace_root: &Path) -> Self {
let current_workspace = workspace_root_key(workspace_root);
let entries: Vec<_> = self
.entries
.iter()
.filter(|entry| entry_belongs_to_workspace(entry, &current_workspace))
.cloned()
.collect();
let selected_name = self
.selected_name
.as_ref()
.filter(|name| entries.iter().any(|entry| entry.name == **name))
.cloned()
.or_else(|| entries.first().map(|entry| entry.name.clone()));
Self {
entries,
selected_name,
}
}
pub(crate) fn selected_index(&self) -> usize { pub(crate) fn selected_index(&self) -> usize {
self.selected_name self.selected_name
.as_ref() .as_ref()
@ -82,6 +132,18 @@ impl PodList {
} }
} }
fn workspace_root_key(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn entry_belongs_to_workspace(entry: &PodListEntry, current_workspace: &Path) -> bool {
entry
.stored
.as_ref()
.and_then(|stored| stored.workspace_root.as_deref())
.is_some_and(|root| workspace_root_key(root) == current_workspace)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodVisibilitySource { pub(crate) enum PodVisibilitySource {
ResumePicker, ResumePicker,
@ -210,6 +272,7 @@ pub(crate) struct StoredPodInfo {
pub active_session_id: Option<SessionId>, pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>, pub active_segment_id: Option<SegmentId>,
pub updated_at: u64, pub updated_at: u64,
pub workspace_root: Option<PathBuf>,
pub preview: Option<String>, pub preview: Option<String>,
} }
@ -348,6 +411,7 @@ fn stored_info_from_metadata(
active_session_id, active_session_id,
active_segment_id, active_segment_id,
updated_at: summary.updated_at, updated_at: summary.updated_at,
workspace_root: metadata.workspace_root,
preview: summary.preview, preview: summary.preview,
} }
} }
@ -359,6 +423,7 @@ fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo {
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
updated_at: 0, updated_at: 0,
workspace_root: None,
preview: Some(format!("metadata: {}", trim_one_line(&message, 48))), preview: Some(format!("metadata: {}", trim_one_line(&message, 48))),
} }
} }
@ -1061,6 +1126,7 @@ mod tests {
active_session_id: Some(session_id), active_session_id: Some(session_id),
active_segment_id: None, active_segment_id: None,
updated_at: 0, updated_at: 0,
workspace_root: None,
preview: Some("[pending segment]".to_string()), preview: Some("[pending segment]".to_string()),
} }
} }
@ -1072,6 +1138,7 @@ mod tests {
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
updated_at, updated_at,
workspace_root: None,
preview: None, preview: None,
} }
} }
@ -1170,4 +1237,72 @@ mod tests {
) )
.unwrap(); .unwrap();
} }
fn stopped_info_for_workspace(pod_name: &str, workspace_root: &Path) -> StoredPodInfo {
let mut info = stopped_info_with_updated_at(pod_name, 10);
info.workspace_root = Some(workspace_root.to_path_buf());
info
}
#[test]
fn workspace_sources_include_current_and_hide_external_or_unknown_pods() {
let current = tempdir().unwrap();
let external = tempdir().unwrap();
let list = PodList::from_workspace_sources(
SOURCE,
vec![
stopped_info_for_workspace("current", current.path()),
stopped_info_for_workspace("current-orchestrator", current.path()),
stopped_info_for_workspace("other-workspace", external.path()),
stopped_info_with_updated_at("legacy-unknown", 10),
corrupt_stored_info("corrupt".to_string(), "invalid metadata".to_string()),
],
vec![
live_info("current", PodStatus::Idle),
live_info("current-orchestrator", PodStatus::Running),
live_info("other-workspace", PodStatus::Idle),
live_info("legacy-unknown", PodStatus::Idle),
live_info("live-only", PodStatus::Idle),
],
None,
10,
current.path(),
);
let names = list
.entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["current", "current-orchestrator"]);
assert!(list.entries.iter().all(|entry| entry.actions.can_open));
}
#[test]
fn workspace_sources_use_workspace_metadata_not_cwd_or_live_presence() {
let current = tempdir().unwrap();
let worktree_cwd = current.path().join(".worktree/impl");
let list = PodList::from_workspace_sources(
SOURCE,
vec![stopped_info_for_workspace("ticket-role", current.path())],
vec![live_info("ticket-role", PodStatus::Idle)],
None,
10,
&worktree_cwd,
);
assert!(list.entries.is_empty());
let list = PodList::from_workspace_sources(
SOURCE,
vec![stopped_info_for_workspace("ticket-role", current.path())],
vec![live_info("ticket-role", PodStatus::Idle)],
None,
10,
current.path(),
);
assert_eq!(list.entries[0].name, "ticket-role");
assert!(list.entries[0].actions.can_open);
}
} }

View File

@ -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, &registry) build_workspace_panel_with_registry(workspace_root, pods, &registry)
} }
#[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,
&registry,
&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,
@ -773,6 +918,8 @@ fn build_workspace_panel_with_registry_model(
pods: &PodList, pods: &PodList,
registry: &PanelRegistrySnapshot, registry: &PanelRegistrySnapshot,
) -> WorkspacePanelViewModel { ) -> WorkspacePanelViewModel {
let pods = pods.filter_for_workspace(workspace_root);
let pods = &pods;
match ticket_config_availability(workspace_root) { match ticket_config_availability(workspace_root) {
TicketConfigAvailability::Absent => {} TicketConfigAvailability::Absent => {}
TicketConfigAvailability::Usable => { TicketConfigAvailability::Usable => {
@ -1089,15 +1236,25 @@ fn ticket_state_display(
) -> String { ) -> String {
match overlay { match overlay {
Some(overlay) => format!( Some(overlay) => format!(
"local: {} · {}: {}", "{}→{}",
local.as_str(), compact_ticket_state_label(local),
overlay.source, compact_ticket_state_label(overlay.workflow_state)
overlay.workflow_state.as_str()
), ),
None => local.as_str().to_string(), None => local.as_str().to_string(),
} }
} }
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
match state {
TicketWorkflowState::Planning => "plan",
TicketWorkflowState::Ready => "ready",
TicketWorkflowState::Queued => "q",
TicketWorkflowState::InProgress => "prog",
TicketWorkflowState::Done => "done",
TicketWorkflowState::Closed => "cls",
}
}
fn apply_orchestration_overlay_to_derived( fn apply_orchestration_overlay_to_derived(
derived: &mut DerivedTicketState, derived: &mut DerivedTicketState,
local: TicketWorkflowState, local: TicketWorkflowState,
@ -1138,51 +1295,90 @@ fn apply_orchestration_overlay_to_derived(
} }
} }
fn format_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
let shown_blockers = blockers.iter().take(3).count();
let mut formatted = blockers
.iter()
.take(3)
.map(|blocker| {
format!(
"{} via {} (state: {})",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
)
})
.collect::<Vec<_>>()
.join(", ");
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
if remaining_blockers > 0 {
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
}
formatted
}
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
matches!(
blocker.blocking_state,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
)
}
fn derive_ticket_state( fn derive_ticket_state(
summary: &TicketSummary, summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker], relation_blockers: &[TicketRelationBlocker],
) -> DerivedTicketState { ) -> DerivedTicketState {
if !relation_blockers.is_empty() { if !relation_blockers.is_empty() {
let shown_blockers = relation_blockers.iter().take(3).count(); let active_blockers = relation_blockers
let mut blockers = relation_blockers
.iter() .iter()
.take(3) .filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.map(|blocker| { .collect::<Vec<_>>();
format!( if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
"{} via {} (state: {})", let blockers_to_report = if active_blockers.is_empty() {
blocker.blocking_ticket, relation_blockers.iter().collect::<Vec<_>>()
blocker.reason_kind, } else {
blocker.blocking_state.as_str() active_blockers
) };
}) let blockers = format_relation_blockers(&blockers_to_report);
.collect::<Vec<_>>() let waiting_reason = format!("waiting for {blockers}");
.join(", "); return DerivedTicketState {
let remaining_blockers = relation_blockers.len().saturating_sub(shown_blockers); kind: match summary.workflow_state {
if remaining_blockers > 0 { TicketWorkflowState::Planning => PanelRowKind::Planning,
blockers.push_str(&format!(" (+{remaining_blockers} more)")); TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
PanelRowKind::ActiveWork
}
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
TicketWorkflowState::Ready => PanelRowKind::Ticket,
},
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
ActionPriority::ActiveWork
}
_ => ActionPriority::Background,
},
action: Some(NextUserAction::Wait),
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
blocked_reason: Some(blockers),
};
} }
let waiting_reason = format!("waiting for {blockers}");
let blockers = format_relation_blockers(
&relation_blockers
.iter()
.collect::<Vec<&TicketRelationBlocker>>(),
);
return DerivedTicketState { return DerivedTicketState {
kind: match summary.workflow_state { kind: PanelRowKind::Ticket,
TicketWorkflowState::Planning => PanelRowKind::Planning, priority: ActionPriority::ReadyForQueue,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { action: Some(NextUserAction::Queue),
PanelRowKind::ActiveWork disabled_reason: None,
} key_hint: Some(format!(
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review, "Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
TicketWorkflowState::Ready => PanelRowKind::Ticket,
},
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
ActionPriority::ActiveWork
}
_ => ActionPriority::Background,
},
action: Some(NextUserAction::Wait),
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
)), )),
key_hint: Some(format!("Gate: {waiting_reason}")), blocked_reason: None,
blocked_reason: Some(blockers),
}; };
} }
@ -1491,7 +1687,7 @@ fn excerpt(markdown: &str, max_chars: usize) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::pod_list::{LivePodInfo, PodEntrySummary}; use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredPodInfo};
use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin}; use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -1635,19 +1831,22 @@ mod tests {
.unwrap_or_else(|| panic!("missing row for {title}")) .unwrap_or_else(|| panic!("missing row for {title}"))
} }
fn status_contains(row: &PanelRow, needle: &str) { fn live_pods(workspace_root: &Path, names: &[&str]) -> PodList {
assert!( let stored = names
row.status.contains(needle), .iter()
"status {:?} did not contain {:?}", .map(|name| StoredPodInfo {
row.status, pod_name: (*name).to_string(),
needle metadata_state: StoredMetadataState::Present,
); active_session_id: None,
} active_segment_id: None,
updated_at: 1,
fn live_pods(names: &[&str]) -> PodList { workspace_root: Some(workspace_root.to_path_buf()),
preview: None,
})
.collect();
PodList::from_sources( PodList::from_sources(
crate::pod_list::PodVisibilitySource::ResumePicker, crate::pod_list::PodVisibilitySource::ResumePicker,
vec![], stored,
names names
.iter() .iter()
.map(|name| LivePodInfo { .map(|name| LivePodInfo {
@ -1670,7 +1869,7 @@ mod tests {
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Hidden Without Config", |_| {}); create_ticket(&backend, "Hidden Without Config", |_| {});
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); let model = build_workspace_panel(temp.path(), &live_pods(temp.path(), &["idle"]));
assert!(model.header.diagnostics.is_empty()); assert!(model.header.diagnostics.is_empty());
assert_eq!( assert_eq!(
@ -1731,8 +1930,7 @@ mod tests {
let model = build_workspace_panel(temp.path(), &empty_pods()); let model = build_workspace_panel(temp.path(), &empty_pods());
let matched = ticket_row_by_title(&model, "Overlay Match"); let matched = ticket_row_by_title(&model, "Overlay Match");
status_contains(matched, "local: queued"); assert_eq!(matched.status, "q→prog");
status_contains(matched, "orchestration: inprogress");
assert_eq!( assert_eq!(
matched.ticket.as_ref().unwrap().workflow_state, matched.ticket.as_ref().unwrap().workflow_state,
TicketWorkflowState::Queued TicketWorkflowState::Queued
@ -1772,8 +1970,7 @@ mod tests {
let model = build_workspace_panel(temp.path(), &empty_pods()); let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Overlay In Progress"); let row = ticket_row_by_title(&model, "Overlay In Progress");
status_contains(row, "local: queued"); assert_eq!(row.status, "q→prog");
status_contains(row, "orchestration: inprogress");
assert_eq!(row.next_action, Some(NextUserAction::Wait)); assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_eq!(row.kind, PanelRowKind::ActiveWork); assert_eq!(row.kind, PanelRowKind::ActiveWork);
assert_eq!(fs::read_to_string(&local_item).unwrap(), before); assert_eq!(fs::read_to_string(&local_item).unwrap(), before);
@ -1801,8 +1998,7 @@ mod tests {
let model = build_workspace_panel(temp.path(), &empty_pods()); let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Overlay Done"); let row = ticket_row_by_title(&model, "Overlay Done");
status_contains(row, "local: queued"); assert_eq!(row.status, "q→done");
status_contains(row, "orchestration: done");
assert_eq!(row.kind, PanelRowKind::Review); assert_eq!(row.kind, PanelRowKind::Review);
assert_eq!(row.next_action, Some(NextUserAction::Wait)); assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_ne!(row.next_action, Some(NextUserAction::Queue)); assert_ne!(row.next_action, Some(NextUserAction::Queue));
@ -1887,7 +2083,7 @@ mod tests {
.unwrap(); .unwrap();
let model = build_workspace_panel_with_registry( let model = build_workspace_panel_with_registry(
temp.path(), temp.path(),
&live_pods(&["ready-intake"]), &live_pods(temp.path(), &["ready-intake"]),
&registry.snapshot().unwrap(), &registry.snapshot().unwrap(),
); );
@ -2005,7 +2201,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); let model = build_workspace_panel(temp.path(), &live_pods(temp.path(), &["idle"]));
let diagnostics = model.header.diagnostics.join("\n"); let diagnostics = model.header.diagnostics.join("\n");
assert!(diagnostics.contains("Ticket config is unusable")); assert!(diagnostics.contains("Ticket config is unusable"));
@ -2123,6 +2319,50 @@ mod tests {
); );
} }
#[test]
fn workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut ready_input = NewTicket::new("Ready After Queued Relation");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
let mut dependency_input = NewTicket::new("Queued Relation Dependency");
dependency_input.workflow_state = Some(TicketWorkflowState::Queued);
let dependency = backend.create(dependency_input).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(ready.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: Some("test".to_string()),
},
)
.unwrap();
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = model
.rows
.iter()
.find(|row| row.title == "Ready After Queued Relation")
.unwrap();
assert_eq!(row.kind, PanelRowKind::Ticket);
assert_eq!(row.next_action, Some(NextUserAction::Queue));
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
assert!(row.disabled_reason.is_none());
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
assert!(
row.key_hint
.as_deref()
.unwrap()
.contains("Queue allowed: prerequisites are already queued/in progress")
);
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
}
#[test] #[test]
fn workspace_panel_defaults_missing_open_state_to_planning_and_displays_done_state() { fn workspace_panel_defaults_missing_open_state_to_planning_and_displays_done_state() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -2194,7 +2434,10 @@ mod tests {
) )
.unwrap(); .unwrap();
let pods = live_pods(&["claimed-intake", "shared-intake", &preticket_pod]); let pods = live_pods(
temp.path(),
&["claimed-intake", "shared-intake", &preticket_pod],
);
let model = let model =
build_workspace_panel_with_registry(temp.path(), &pods, &registry.snapshot().unwrap()); build_workspace_panel_with_registry(temp.path(), &pods, &registry.snapshot().unwrap());
@ -2258,7 +2501,7 @@ mod tests {
let model = build_workspace_panel_with_registry( let model = build_workspace_panel_with_registry(
temp.path(), temp.path(),
&live_pods(&["ticket-claimed-intake"]), &live_pods(temp.path(), &["ticket-claimed-intake"]),
&registry, &registry,
); );
let row = model let row = model
@ -2466,4 +2709,104 @@ mod tests {
OrchestratorLifecyclePlan::ReportLive OrchestratorLifecyclePlan::ReportLive
); );
} }
fn mixed_workspace_pods(current: &Path, external: &Path) -> PodList {
let stored = vec![
StoredPodInfo {
pod_name: "current".to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: None,
active_segment_id: None,
updated_at: 10,
workspace_root: Some(current.to_path_buf()),
preview: None,
},
StoredPodInfo {
pod_name: "current-coder".to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: None,
active_segment_id: None,
updated_at: 20,
workspace_root: Some(current.to_path_buf()),
preview: None,
},
StoredPodInfo {
pod_name: "external".to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: None,
active_segment_id: None,
updated_at: 30,
workspace_root: Some(external.to_path_buf()),
preview: None,
},
StoredPodInfo {
pod_name: "legacy".to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: None,
active_segment_id: None,
updated_at: 40,
workspace_root: None,
preview: None,
},
StoredPodInfo {
pod_name: "corrupt".to_string(),
metadata_state: StoredMetadataState::Corrupt("bad metadata".to_string()),
active_session_id: None,
active_segment_id: None,
updated_at: 50,
workspace_root: None,
preview: Some("metadata: bad metadata".to_string()),
},
];
let live = [
"current",
"current-coder",
"external",
"legacy",
"live-only",
]
.iter()
.map(|name| LivePodInfo {
pod_name: (*name).to_string(),
socket_path: PathBuf::from(format!("/tmp/{name}.sock")),
status: Some(PodStatus::Idle),
reachable: true,
segment_id: None,
summary: PodEntrySummary::default(),
})
.collect();
PodList::from_sources(
crate::pod_list::PodVisibilitySource::ResumePicker,
stored,
live,
None,
10,
)
}
#[test]
fn workspace_panel_filters_pod_rows_to_current_workspace_metadata() {
let current = TempDir::new().unwrap();
let external = TempDir::new().unwrap();
let pods = mixed_workspace_pods(current.path(), external.path());
let model = build_workspace_panel(current.path(), &pods);
let pod_names = model
.rows
.iter()
.filter_map(|row| match &row.key {
PanelRowKey::Pod(name) => Some(name.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(pod_names, vec!["current-coder", "current"]);
assert!(
model
.rows
.iter()
.filter(|row| matches!(row.key, PanelRowKey::Pod(_)))
.all(|row| row.next_action == Some(NextUserAction::OpenPod))
);
}
} }

View File

@ -12,6 +12,12 @@ pkgs.mkShell {
openssl openssl
]; ];
shellHook = '' shellHook = ''
if repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
export YOI_POD_RUNTIME_COMMAND="$repo_root/target/debug/yoi"
else
export YOI_POD_RUNTIME_COMMAND="$PWD/target/debug/yoi"
fi
echo "dev-shell-loaded" echo "dev-shell-loaded"
echo "YOI_POD_RUNTIME_COMMAND=$YOI_POD_RUNTIME_COMMAND"
''; '';
} }

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-Y1siH1oDe9It7ntx83DJO5fzV9LtC7+qq9V6RPlRxUY="; cargoHash = "sha256-ud+3INcXnT5W26Bz0K4QXUqoqw3p/ER9c4F2Fhq3YuQ=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -264,15 +264,290 @@ 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,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpectedPanelTicketRow {
pub id: String,
pub title: 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 {
pub fn new(id: impl Into<String>, title: impl Into<String>, status: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
status: status.into(),
action: None,
disabled_reason: None,
local_state: None,
overlay_state: None,
}
}
pub fn with_action(mut self, action: impl Into<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 {
row.key.kind == "ticket"
&& row.key.id == self.id
&& row.title == self.title
&& row.status.as_deref() == Some(self.status.as_str())
&& self.action.as_ref().map_or(true, |action| {
row.action.as_deref() == Some(action.as_str())
})
&& self.disabled_reason.as_ref().map_or(true, |reason| {
row.disabled_reason
.as_deref()
.is_some_and(|actual| actual.contains(reason))
})
&& self.local_state.as_ref().map_or(true, |state| {
row.local_state.as_deref() == Some(state.as_str())
})
&& self.overlay_state.as_ref().map_or(true, |state| {
row.overlay_state.as_deref() == Some(state.as_str())
})
}
fn description(&self) -> String {
format!(
"ticket row id={} title={:?} status={} action={:?} disabled_reason={:?} local_state={:?} overlay_state={:?}",
self.id,
self.title,
self.status,
self.action,
self.disabled_reason,
self.local_state,
self.overlay_state,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpectedDashboardContent {
pub tickets: Vec<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>,
pub rows: Vec<RenderedPanelRow>, pub rows: Vec<RenderedPanelRow>,
} }
impl RowsRendered {
pub fn fixture_ticket_row(
&self,
expected: &ExpectedPanelTicketRow,
) -> Option<&RenderedPanelRow> {
self.rows.iter().find(|row| expected.matches(row))
}
pub fn has_fixture_ticket_row(&self, expected: &ExpectedPanelTicketRow) -> bool {
self.fixture_ticket_row(expected).is_some()
}
}
fn rows_rendered_event_has_fixture_ticket(
event: &HarnessEvent,
expected: &ExpectedPanelTicketRow,
) -> bool {
serde_json::from_value::<RowsRendered>(event.data.clone())
.map(|rows| rows.has_fixture_ticket_row(expected))
.unwrap_or(false)
}
fn describe_rows(rows: &RowsRendered) -> String {
rows.rows
.iter()
.map(|row| {
format!(
"{}:{} title={:?} status={:?}",
row.key.kind, row.key.id, row.title, row.status
)
})
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum KeyPress { pub enum KeyPress {
CtrlC, CtrlC,
@ -445,6 +720,112 @@ impl PanelHarness {
} }
} }
/// Waits for the legacy `panel_ready` observer event, which means the first
/// panel frame was painted. It intentionally does not mean workspace rows or
/// Ticket data are ready.
pub fn wait_for_first_visible_frame(&mut self, timeout: Duration) -> Result<HarnessEvent> {
self.wait_for(
"first visible panel frame (panel_ready, before rows readiness)",
timeout,
|event| event.event == "panel_ready",
)
}
/// Waits until a concrete fixture Ticket row has rendered. This is the
/// startup rows-ready signal; it validates id + title + state rather than
/// using only the number of rendered rows.
pub fn wait_for_fixture_ticket_rows_ready(
&mut self,
expected: &ExpectedPanelTicketRow,
timeout: Duration,
) -> Result<RowsRendered> {
let description = expected.description();
let event = self.wait_for(
format!("fixture Ticket rows ready ({description})"),
timeout,
|event| {
event.event == "rows_rendered"
&& rows_rendered_event_has_fixture_ticket(event, expected)
},
)?;
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(
&mut self,
expected: &ExpectedPanelTicketRow,
duration: Duration,
) -> Result<()> {
let start = Instant::now();
while start.elapsed() < duration {
if let Some(rows) = self
.events()?
.iter()
.filter(|event| event.event == "rows_rendered")
.filter_map(|event| serde_json::from_value::<RowsRendered>(event.data.clone()).ok())
.find(|rows| rows.has_fixture_ticket_row(expected))
{
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"fixture Ticket row rendered before data-backed rows readiness was expected: {}; rows: {}",
expected.description(),
describe_rows(&rows)
)));
}
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} while asserting fixture Ticket rows stayed delayed"
)));
}
thread::sleep(Duration::from_millis(20));
}
Ok(())
}
pub fn wait_for_rows(&mut self, min_rows: usize) -> Result<RowsRendered> { pub fn wait_for_rows(&mut self, min_rows: usize) -> Result<RowsRendered> {
let event = self.wait_for("rows_rendered", DEFAULT_WAIT, |event| { let event = self.wait_for("rows_rendered", DEFAULT_WAIT, |event| {
event.event == "rows_rendered" event.event == "rows_rendered"
@ -781,6 +1162,9 @@ pub struct FixtureCleanupReport {
pub report_path: PathBuf, pub report_path: PathBuf,
} }
pub const READY_FIXTURE_TICKET_TITLE: &str = "Ready E2E Ticket";
pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket";
#[derive(Debug)] #[derive(Debug)]
pub struct FixtureWorkspace { pub struct FixtureWorkspace {
temp_root: Option<TempDir>, temp_root: Option<TempDir>,
@ -792,6 +1176,8 @@ pub struct FixtureWorkspace {
pub xdg_config_home: PathBuf, pub xdg_config_home: PathBuf,
pub xdg_runtime_dir: PathBuf, pub xdg_runtime_dir: PathBuf,
pub artifacts_dir: PathBuf, pub artifacts_dir: PathBuf,
pub ready_ticket_id: String,
pub planning_ticket_id: String,
} }
impl FixtureWorkspace { impl FixtureWorkspace {
@ -832,7 +1218,7 @@ impl FixtureWorkspace {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
} }
let fixture = Self { let mut fixture = Self {
temp_root: Some(temp_root), temp_root: Some(temp_root),
root, root,
workspace, workspace,
@ -842,6 +1228,8 @@ impl FixtureWorkspace {
xdg_config_home, xdg_config_home,
xdg_runtime_dir, xdg_runtime_dir,
artifacts_dir, artifacts_dir,
ready_ticket_id: String::new(),
planning_ticket_id: String::new(),
}; };
fixture.write_fixture_metadata("created", None)?; fixture.write_fixture_metadata("created", None)?;
@ -867,7 +1255,7 @@ impl FixtureWorkspace {
&fixture.xdg_config_home, &fixture.xdg_config_home,
&fixture.xdg_runtime_dir, &fixture.xdg_runtime_dir,
&fixture.artifacts_dir, &fixture.artifacts_dir,
"Ready E2E Ticket", READY_FIXTURE_TICKET_TITLE,
)?; )?;
run_yoi( run_yoi(
binary, binary,
@ -880,7 +1268,7 @@ impl FixtureWorkspace {
&fixture.artifacts_dir, &fixture.artifacts_dir,
&["ticket", "state", &first, "ready"], &["ticket", "state", &first, "ready"],
)?; )?;
let _second = create_ticket( let second = create_ticket(
binary, binary,
&fixture.workspace, &fixture.workspace,
&fixture.home, &fixture.home,
@ -889,12 +1277,112 @@ impl FixtureWorkspace {
&fixture.xdg_config_home, &fixture.xdg_config_home,
&fixture.xdg_runtime_dir, &fixture.xdg_runtime_dir,
&fixture.artifacts_dir, &fixture.artifacts_dir,
"Planning E2E Ticket", PLANNING_FIXTURE_TICKET_TITLE,
)?; )?;
fixture.ready_ticket_id = first;
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)
} }
pub fn ready_fixture_ticket_row(&self) -> ExpectedPanelTicketRow {
ExpectedPanelTicketRow::new(
self.ready_ticket_id.clone(),
READY_FIXTURE_TICKET_TITLE,
"ready",
)
.with_action("Queue")
.with_local_state("ready")
}
pub fn ready_overlay_ticket_row(&self) -> ExpectedPanelTicketRow {
ExpectedPanelTicketRow::new(
self.ready_ticket_id.clone(),
READY_FIXTURE_TICKET_TITLE,
"ready→prog",
)
.with_action("Wait")
.with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress")
.with_local_state("ready")
.with_overlay_state("inprogress")
}
pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow {
ExpectedPanelTicketRow::new(
self.planning_ticket_id.clone(),
PLANNING_FIXTURE_TICKET_TITLE,
"planning",
)
.with_action("Clarify")
.with_disabled_reason("Ticket is still in planning")
.with_local_state("planning")
}
pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent {
ExpectedDashboardContent {
tickets: vec![
self.ready_overlay_ticket_row(),
self.planning_fixture_ticket_row(),
],
pod_names: vec!["workspace".to_string()],
companion_status: "spawned".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 {
PanelHarnessConfig { PanelHarnessConfig {
binary, binary,
@ -999,6 +1487,18 @@ impl FixtureWorkspace {
"xdg_config_home": &self.xdg_config_home, "xdg_config_home": &self.xdg_config_home,
"xdg_runtime_dir": &self.xdg_runtime_dir, "xdg_runtime_dir": &self.xdg_runtime_dir,
"artifacts_dir": &self.artifacts_dir, "artifacts_dir": &self.artifacts_dir,
"tickets": {
"ready": {
"id": &self.ready_ticket_id,
"title": READY_FIXTURE_TICKET_TITLE,
"state": "ready"
},
"planning": {
"id": &self.planning_ticket_id,
"title": PLANNING_FIXTURE_TICKET_TITLE,
"state": "planning"
}
},
"env_runtime_policy": { "env_runtime_policy": {
"tested_yoi_uses_env_clear": true, "tested_yoi_uses_env_clear": true,
"host_runtime_inherited": false, "host_runtime_inherited": false,
@ -1221,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,

View File

@ -1,17 +1,229 @@
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 FULL_READY_BUDGET: Duration = Duration::from_secs(5); const DASHBOARD_CONTENT_READY_BUDGET: Duration = Duration::from_secs(5);
use yoi_e2e::{ use yoi_e2e::{
FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, RenderedPanelRow, yoi_binary, DashboardCompanionState, DashboardContentCategories, DashboardContentReady, DashboardHeader,
DashboardOrchestratorState, DashboardSnapshot, ExpectedDashboardContent,
ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness,
PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary,
}; };
fn rendered_ticket_row(
id: &str,
title: &str,
status: &str,
action: Option<&str>,
disabled_reason: Option<&str>,
local_state: Option<&str>,
overlay_state: Option<&str>,
) -> RenderedPanelRow {
RenderedPanelRow {
key: PanelRowKey {
kind: "ticket".to_string(),
id: id.to_string(),
},
title: title.to_string(),
status: Some(status.to_string()),
action: action.map(ToOwned::to_owned),
disabled_reason: disabled_reason.map(ToOwned::to_owned),
local_state: local_state.map(ToOwned::to_owned),
overlay_state: overlay_state.map(ToOwned::to_owned),
overlay_detail: overlay_state.map(|state| format!("orchestration:{state}")),
rect: PanelRect {
x: 0,
y: 0,
width: 10,
height: 1,
},
}
}
fn rendered_pod_row(name: &str) -> RenderedPanelRow {
RenderedPanelRow {
key: PanelRowKey {
kind: "pod".to_string(),
id: name.to_string(),
},
title: name.to_string(),
status: None,
action: None,
disabled_reason: None,
local_state: None,
overlay_state: None,
overlay_detail: None,
rect: PanelRect {
x: 0,
y: 1,
width: 10,
height: 1,
},
}
}
fn ready_snapshot(rows: Vec<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 {
key: PanelRowKey {
kind: "pod".to_string(),
id: "0000000000000".to_string(),
},
title: "Ready E2E Ticket".to_string(),
status: Some("ready".to_string()),
action: Some("Queue".to_string()),
disabled_reason: None,
local_state: Some("ready".to_string()),
overlay_state: None,
overlay_detail: None,
rect: PanelRect {
x: 0,
y: 0,
width: 10,
height: 1,
},
};
assert!(!expected.matches(&wrong_title));
assert!(!expected.matches(&wrong_kind));
let rows = RowsRendered {
selected: None,
rows: vec![wrong_title, wrong_kind],
};
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()?;
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 started = Instant::now(); let started = Instant::now();
let mut panel = let mut panel =
@ -19,17 +231,15 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res
let remaining = FIRST_VISIBLE_RENDER_BUDGET let remaining = FIRST_VISIBLE_RENDER_BUDGET
.checked_sub(started.elapsed()) .checked_sub(started.elapsed())
.unwrap_or_else(|| Duration::from_millis(0)); .unwrap_or_else(|| Duration::from_millis(0));
panel.wait_for("first visible panel render", remaining, |event| { panel.wait_for_first_visible_frame(remaining)?;
event.event == "panel_ready"
})?;
let first_visible_elapsed = started.elapsed(); let first_visible_elapsed = started.elapsed();
eprintln!( eprintln!(
"panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
assert!( assert!(
first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET,
"first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
@ -42,7 +252,7 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res
events[..ready_index] events[..ready_index]
.iter() .iter()
.all(|event| event.event != "background_task_started"), .all(|event| event.event != "background_task_started"),
"initial render must be emitted before reload/background work starts; artifacts at {}", "initial frame must be emitted before reload/background work starts; artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
@ -54,12 +264,13 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res
event.event == "background_task_started" event.event == "background_task_started"
&& event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload") && event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload")
}) })
.expect("held reload should start after first visible render"); .expect("held reload should start after first visible frame");
assert!( assert!(
ready_index < reload_started_index, ready_index < reload_started_index,
"first visible render and reload ordering should remain separate; artifacts at {}", "first visible frame and reload ordering should remain separate; artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
panel.assert_fixture_ticket_row_not_rendered(&ready_ticket, Duration::from_millis(150))?;
panel.press(KeyPress::CtrlC)?; panel.press(KeyPress::CtrlC)?;
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
@ -70,51 +281,86 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res
} }
#[test] #[test]
fn panel_full_ready_has_separate_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 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))?;
let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET
.checked_sub(started.elapsed()) .checked_sub(started.elapsed())
.unwrap_or_else(|| Duration::from_millis(0)); .unwrap_or_else(|| Duration::from_millis(0));
panel.wait_for( panel.wait_for_first_visible_frame(first_visible_remaining)?;
"first visible panel render",
first_visible_remaining,
|event| event.event == "panel_ready",
)?;
let first_visible_elapsed = started.elapsed(); let first_visible_elapsed = started.elapsed();
eprintln!( eprintln!(
"panel first visible render: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
assert!( assert!(
first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET,
"first visible render took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
let full_ready_remaining = FULL_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));
panel.wait_for("full ready fixture rows", full_ready_remaining, |event| { let content_ready =
event.event == "rows_rendered" panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?;
&& event assert!(
.data content_ready.snapshot.header.ticket_configured,
.get("rows") "dashboard content ready must include usable Ticket configuration; artifacts at {}",
.and_then(serde_json::Value::as_array)
.is_some_and(|rows| rows.len() >= 2)
})?;
let full_ready_elapsed = started.elapsed();
eprintln!(
"panel full ready: {full_ready_elapsed:?} (budget {FULL_READY_BUDGET:?}); artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
assert!( assert!(
full_ready_elapsed <= FULL_READY_BUDGET, content_ready.snapshot.header.companion.is_some()
"full ready took {full_ready_elapsed:?}, budget {FULL_READY_BUDGET:?}; artifacts at {}", && content_ready.snapshot.header.orchestrator.is_some(),
"dashboard content ready must include Companion and Orchestrator header status; got {:?}; artifacts at {}",
content_ready.snapshot.header,
panel.artifacts().dir.display()
);
assert_eq!(
content_ready.snapshot_for_expected(&expected_content),
expected_content.snapshot(),
"dashboard content ready must match expected Ticket/action/overlay/header snapshot; artifacts at {}",
panel.artifacts().dir.display()
);
assert!(
content_ready.categories.ready_ticket_rows > 0
&& content_ready.categories.planning_ticket_rows > 0
&& content_ready.categories.pod_rows > 0,
"dashboard content ready must include ready Ticket, planning Ticket, and Pod categories; got {:?}; artifacts at {}",
content_ready.categories,
panel.artifacts().dir.display()
);
let content_ready_elapsed = started.elapsed();
eprintln!(
"panel dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}",
panel.artifacts().dir.display()
);
assert!(
content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET,
"dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}",
panel.artifacts().dir.display()
);
let source_breakdown = panel.expect_dashboard_source_breakdown()?;
assert!(
source_breakdown.has_source("pod_metadata_status_probe.initial")
&& source_breakdown.has_source("ticket_config_probe")
&& source_breakdown.has_source("local_claim_scan")
&& source_breakdown.has_source("ticket_scan_parse")
&& source_breakdown.has_source("orchestration_overlay_validation_read_git")
&& source_breakdown.has_source("workspace_panel.build.total"),
"dashboard source breakdown should include pod metadata/status, ticket scan/parse, overlay validation/read/git, local claim scan, and panel-build sources; got {:?}; artifacts at {}",
source_breakdown,
panel.artifacts().dir.display()
);
eprintln!(
"panel dashboard source breakdown: {:?}; artifacts at {}",
source_breakdown,
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );