merge: integrate orchestration branch
# Conflicts: # .yoi/tickets/00001KV10SN02/item.md # .yoi/tickets/00001KV10SN02/thread.md
This commit is contained in:
commit
0248db80e1
|
|
@ -0,0 +1 @@
|
||||||
|
{"id":"orch-plan-20260613-184114-1","ticket_id":"00001KV09X0XC","kind":"accepted_plan","accepted_plan":{"summary":"Add a Panel ready-ticket action that records user refinement instructions, transitions ready -> planning through typed backend after state refresh, and attempts Intake restore/launch without queue/implementation side effects.","branch":"ticket-00001KV09X0XC-panel-return-planning","worktree":"/home/hare/Projects/yoi/.worktree/panel-return-planning","role_plan":"Coder works in dedicated Panel/Ticket action worktree; Reviewer focuses on lifecycle authority, stale-state rejection, no implementation side effects, and Intake failure diagnostics."},"author":"orchestrator","at":"2026-06-13T18:41:14Z"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Panel から ready Ticket を指示付きで planning に戻して Intake を再開できるようにする'
|
title: 'Panel から ready Ticket を指示付きで planning に戻して Intake を再開できるようにする'
|
||||||
state: 'queued'
|
state: 'done'
|
||||||
created_at: '2026-06-13T10:54:34Z'
|
created_at: '2026-06-13T10:54:34Z'
|
||||||
updated_at: '2026-06-13T16:33:26Z'
|
updated_at: '2026-06-14T05:09:07Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['panel-action', 'ticket-lifecycle', 'role-session', 'authority-boundary']
|
risk_flags: ['panel-action', 'ticket-lifecycle', 'role-session', 'authority-boundary']
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,157 @@ Intake refinement completed. ユーザーが draft を承認し、意図・受
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-06-13T18:41:14Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Ticket は `queued` で、ready Ticket を Panel からユーザー指示付きで `planning` に戻し Intake を再開する action の intent / requirements / acceptance criteria / invariants が具体化されている。
|
||||||
|
- `TicketRelationQuery` / `TicketOrchestrationPlanQuery` に blocker はない。
|
||||||
|
- Risk は panel-action / ticket-lifecycle / role-session / authority-boundary だが、対象は `ready -> planning` のみ、typed backend 経由、Queue/Orchestrator/worktree/coder side effect 不発生という binding invariants が明記済み。
|
||||||
|
- `00001KV0X254D` と同じ Panel 周辺に触れる可能性はあるが action dispatch と orchestration branch config は論理的に分離できる。merge conflict が出たらその Ticket を止めて報告する。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- relation records: なし。
|
||||||
|
- orchestration plan records: なし。
|
||||||
|
- workspace state: Orchestrator worktree clean、dedicated child worktree で実装予定。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
- Panel の ready Ticket row から Queue とは別に refinement/intake 再開 action を実行できるようにし、ユーザー指示を Ticket thread に保存して typed `ready -> planning` を記録し、Intake restore/launch を試行する。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- この action は implementation routing ではなく requirements sync への戻し。
|
||||||
|
- `queued` / `inprogress` は範囲外。
|
||||||
|
- stale state は再読込して拒否する。
|
||||||
|
- Queue、`queued -> inprogress`、worktree 作成、coder/reviewer spawn は発生させない。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- Focused Panel/action tests、Ticket lifecycle/role-session tests、`cargo test -p tui workspace_panel`、`cargo test -p ticket`、`cargo fmt --check`、`git diff --check`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-13T18:41:25Z from: queued to: inprogress reason: orchestrator_acceptance_unblocked field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket evidence、relation records、orchestration plan、workspace state を確認した。Blocking relation はなく、implementation-ready。実装 side effect の前に inprogress acceptance を記録する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-13T18:42:45Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation handoff started.
|
||||||
|
|
||||||
|
- Worktree: `/home/hare/Projects/yoi/.worktree/panel-return-planning`
|
||||||
|
- Branch: `ticket-00001KV09X0XC-panel-return-planning`
|
||||||
|
- Coder Pod: `coder-00001KV09X0XC-return-planning`
|
||||||
|
- Scope: root workspace non-recursive read for launch validation only; child worktree read; writes limited to relevant TUI/Ticket/Client/Yoi/Pod role-session surfaces, target, and this Ticket record。
|
||||||
|
|
||||||
|
Next: Coder implementation_report / commit / validation evidence を確認後、Reviewer を起動する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-06-13T19:01:36Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Commit: `f74146c6` (`tui: return ready tickets to planning from panel`)
|
||||||
|
|
||||||
|
Files changed:
|
||||||
|
- `crates/tui/src/multi_pod.rs`
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- Added a separate Panel path for selected `ready` Ticket rows when the composer target is Ticket Intake and the user submits non-empty refinement instructions.
|
||||||
|
- The action rereads the Ticket through the typed local Ticket backend and records a typed `ready -> planning` `state_changed` event with the user's refinement instruction in the Ticket thread.
|
||||||
|
- The action does not invoke Queue, `queued -> inprogress`, worktree creation, or implementation-role spawning.
|
||||||
|
- After the state change it attempts the Intake continuation path:
|
||||||
|
- launch and claim a new Intake Pod when no local Intake claim exists,
|
||||||
|
- notify a live claimed Intake Pod,
|
||||||
|
- open/restore a restorable claimed Intake Pod,
|
||||||
|
- or leave a visible diagnostic for stale claims without launching a duplicate.
|
||||||
|
- Added focused tests for stale-state rejection, state/event recording, action dispatch selection, launch-after-mutation followup, and role-session claim recording for post-launch Intake Pods.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo test -p tui planning_return`
|
||||||
|
- `cargo test -p tui intake_registry`
|
||||||
|
- `cargo test -p tui workspace_panel`
|
||||||
|
- `cargo test -p ticket`
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
Residual risks:
|
||||||
|
- No real terminal/PTTY Panel E2E was run; coverage is focused unit/action-path validation.
|
||||||
|
- The existing Panel composer model exposes this via Ticket Intake target + selected ready row + non-empty text, rather than adding a new visible button/menu surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: hare at: 2026-06-13T19:07:43Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Evidence reviewed:
|
||||||
|
- Inspected `git diff c4465a04..HEAD`; source changes are confined to `crates/tui/src/multi_pod.rs` plus Ticket record/report updates.
|
||||||
|
- Panel dispatch now has a separate `ReturnReadyTicketToPlanning` path for non-empty composer text when the composer target is Ticket Intake and the selected row action is `Queue`/ready.
|
||||||
|
- The mutation path reloads the Ticket from `LocalTicketBackend`, rejects non-`ready` current state, and records a typed `ready -> planning` `state_changed` event with author `workspace-panel`, reason `panel_return_to_planning`, and the user instruction in the thread body.
|
||||||
|
- The follow-up paths are Intake-only: launch when unclaimed, notify live claimed Intake, open/restorable claimed Intake, or diagnose stale claim without duplicate launch. I did not find calls to queue dispatch, `queued -> inprogress`, worktree creation, Orchestrator/Coder/Reviewer spawn in this path.
|
||||||
|
- Tests cover successful planning return, stale-state rejection, dispatch separation from Queue/generic Intake launch, state-before-launch follow-up, and launched-claim registry handling. Existing queue action coverage remains present.
|
||||||
|
|
||||||
|
Validation run:
|
||||||
|
- `cargo test -p tui planning_return` — pass (4 tests)
|
||||||
|
- `cargo test -p tui intake_registry` — pass (4 tests)
|
||||||
|
- `cargo test -p tui workspace_panel` — pass (12 tests)
|
||||||
|
- `cargo test -p ticket` — pass (68 tests + doctests)
|
||||||
|
- `cargo fmt --check` — pass
|
||||||
|
- `git diff --check c4465a04..HEAD` — pass
|
||||||
|
|
||||||
|
Residual notes:
|
||||||
|
- No real terminal/PTTY Panel E2E was run; this remains a unit/action-path review only.
|
||||||
|
- Discoverability depends on the existing composer target model: select a ready Ticket row, switch to Ticket Intake, type non-empty refinement instructions, then Enter. The implementation makes this visible in status/actionbar text; no separate button/menu was added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-14T05:08:58Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Integration outcome: merged and validated.
|
||||||
|
|
||||||
|
- Implementation branch: `ticket-00001KV09X0XC-panel-return-planning`
|
||||||
|
- Implementation commits: `f74146c6 tui: return ready tickets to planning from panel`, `cb565477 ticket: report panel planning return implementation`
|
||||||
|
- Review commit: `9d4abe50 ticket: approve panel planning return`
|
||||||
|
- Orchestrator merge commit: `7a6321d9 merge: panel return planning`
|
||||||
|
|
||||||
|
Reviewer result:
|
||||||
|
- `approve`。ready Ticket row + Ticket Intake target + non-empty instruction のみで typed `ready -> planning` を記録し、stale state を拒否し、Queue / worktree / implementation Pod side effects を発生させないことを確認済み。
|
||||||
|
|
||||||
|
Orchestrator validation after merge:
|
||||||
|
- `cargo test -p tui planning_return`: PASS
|
||||||
|
- `cargo test -p tui intake_registry`: PASS
|
||||||
|
- `cargo test -p tui workspace_panel`: PASS
|
||||||
|
- `cargo test -p ticket`: PASS
|
||||||
|
- `cargo fmt --check`: PASS
|
||||||
|
- `git diff --check`: PASS
|
||||||
|
|
||||||
|
Residual notes:
|
||||||
|
- Review note の通り、real terminal/PTTY Panel E2E は未実施。coverage は focused unit/action-path validation。
|
||||||
|
- Discoverability は既存 composer target model(Ticket Intake target + ready row selection + non-empty text)に依存し、独立ボタン/メニューは追加していない。
|
||||||
|
|
||||||
|
Next:
|
||||||
|
- Mark Ticket done and clean up child coder/reviewer Pods plus implementation worktree/branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-14T05:09:07Z from: inprogress to: done reason: merged_and_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation branch was reviewed, approved, merged into the Orchestrator branch as `7a6321d9`, and validated in the Orchestrator worktree. Focused TUI planning-return/intake/workspace-panel tests, Ticket tests, formatting, and diff check passed. Ticket implementation work is done; closure remains separate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"id":"orch-plan-20260613-184114-1","ticket_id":"00001KV0SP0TY","kind":"accepted_plan","accepted_plan":{"summary":"Remove feature-layer HostAuthority/grant model from pod::feature and built-in feature install paths, preserving contribution diagnostics and Ticket feature config/backend validation without introducing replacement permission semantics.","branch":"ticket-00001KV0SP0TY-remove-feature-hostauthority","worktree":"/home/hare/Projects/yoi/.worktree/remove-feature-hostauthority","role_plan":"Coder performs API cleanup in dedicated worktree; Reviewer focuses on no replacement authority layer, Ticket feature access preservation, and Plugin/MCP permission non-goals."},"author":"orchestrator","at":"2026-06-13T18:41:14Z"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Remove feature-layer HostAuthority model'
|
title: 'Remove feature-layer HostAuthority model'
|
||||||
state: 'queued'
|
state: 'done'
|
||||||
created_at: '2026-06-13T15:30:22Z'
|
created_at: '2026-06-13T15:30:22Z'
|
||||||
updated_at: '2026-06-13T16:33:15Z'
|
updated_at: '2026-06-13T19:02:01Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['feature-api', 'tool-registry', 'ticket-tools']
|
risk_flags: ['feature-api', 'tool-registry', 'ticket-tools']
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,167 @@ Marked ready by `yoi ticket state`.
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-06-13T18:41:14Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Ticket は `queued` で、feature-layer `HostAuthority*` model を削除し、permission/trust は Plugin/MCP/Ticket/tool owning layers に置く decision が明確。
|
||||||
|
- `TicketRelationQuery` には incoming `depends_on`(`00001KTR81P9X` がこの Ticket に依存)があるが、この Ticket 自身を blocking する relation はない。むしろ後続 dynamic provider work の前提として先に進めるべき。
|
||||||
|
- `TicketOrchestrationPlanQuery` に blocker はない。
|
||||||
|
- Risk は feature-api / tool-registry / ticket-tools だが、削除対象・非目標(Plugin/MCP permission policy を導入しない)が明記済み。
|
||||||
|
- 他 queued work と主な変更面が異なるため並列開始可能。API cleanup の影響が大きい場合は Coder に escalation させる。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- relation records: incoming dependency from `00001KTR81P9X` only。
|
||||||
|
- orchestration plan records: なし。
|
||||||
|
- bounded code context: `HostAuthority*` occurrences across `crates/pod` / feature registry / ticket/task feature tests。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
- `pod::feature` public API から `HostAuthority`, `HostAuthorityRequest`, `HostAuthorityGrantSet` と related grant/install report semantics を削除し、ordinary contribution diagnostics に整理する。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- Renamed feature-layer authority/grant model は作らない。
|
||||||
|
- Plugin permission policy / MCP trust policy はこの Ticket で実装しない。
|
||||||
|
- Ticket tools は explicit TicketFeatureConfig / backend validation / access-level config を維持する。
|
||||||
|
- Useful contribution checks(duplicate names、undeclared categories等)は残す。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- Focused pod feature / ticket / task tests、`cargo fmt --check`、`cargo check --workspace --all-targets`、可能なら `nix build .#yoi`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-13T18:41:25Z from: queued to: inprogress reason: orchestrator_acceptance_unblocked field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket evidence、relation records、orchestration plan、workspace state を確認した。This Ticket 自身を blocking する relation はなく、incoming dependent Ticket の前提として先に進めるべき。実装 side effect の前に inprogress acceptance を記録する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-13T18:42:45Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation handoff started.
|
||||||
|
|
||||||
|
- Worktree: `/home/hare/Projects/yoi/.worktree/remove-feature-hostauthority`
|
||||||
|
- Branch: `ticket-00001KV0SP0TY-remove-feature-hostauthority`
|
||||||
|
- Coder Pod: `coder-00001KV0SP0TY-hostauthority`
|
||||||
|
- Scope: root workspace non-recursive read for launch validation only; child worktree read; writes limited to relevant feature/Ticket/tool/client/workflow/manifest/docs surfaces, target, and this Ticket record。
|
||||||
|
|
||||||
|
Next: Coder implementation_report / commit / validation evidence を確認後、Reviewer を起動する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-06-13T18:53:02Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Commit: `5549c50d8679` (`feat: remove feature host authority API`)
|
||||||
|
|
||||||
|
Files changed:
|
||||||
|
- `crates/pod/src/feature.rs`
|
||||||
|
- Removed `HostAuthority`, `HostAuthorityRequest`, `HostAuthorityGrantSet`, host-authority denial errors, grant plumbing, and per-tool host-authority gates from the public feature API/install path.
|
||||||
|
- Kept ordinary contribution declaration diagnostics: duplicate names, undeclared/mismatched tools, undeclared hooks/background tasks/services, skipped contributions, and service requirement diagnostics.
|
||||||
|
- Kept install reports focused on installed/skipped contributions and diagnostics without security grant semantics.
|
||||||
|
- `crates/pod/src/feature/builtin/ticket.rs`
|
||||||
|
- Stopped declaring TicketBackend host authority and registering Ticket tools with feature-layer authority requirements.
|
||||||
|
- Preserved explicit `TicketFeatureConfig`, backend provider/root validation, and access-level-controlled tool exposure.
|
||||||
|
- `crates/pod/src/feature/builtin/task/mod.rs`
|
||||||
|
- Removed Task feature prose that referenced feature-layer host authorities.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo test -p pod feature:: --lib` — passed (46 tests).
|
||||||
|
- `cargo test -p pod feature::builtin::task --lib` — passed (18 tests).
|
||||||
|
- `cargo test -p ticket` — passed (68 tests + doc-tests).
|
||||||
|
- `cargo fmt --check` — passed.
|
||||||
|
- `cargo check --workspace --all-targets` — passed.
|
||||||
|
- `git diff --check` — passed before commit.
|
||||||
|
- Source grep under `crates/**/*.rs` found no remaining `HostAuthority*`, `host_authority`, `with_host_authority`, `requested_host_authorities`, or `required_host_authorities` symbols/terminology after cleanup.
|
||||||
|
|
||||||
|
Residual risks / notes:
|
||||||
|
- `nix build .#yoi` was not run; this change does not modify dependencies, `Cargo.lock`, packaging, or source filters.
|
||||||
|
- Related Plugin/MCP planning Ticket review was not performed from this delegated Coder scope because only Ticket `00001KV0SP0TY` is readable/writable in `.yoi/tickets`; no Plugin/MCP permission or trust policy was implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: hare at: 2026-06-13T18:58:42Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Reviewed implementation commits `5549c50d` and `3faf7d7b` against Ticket `00001KV0SP0TY`.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `crates/pod/src/feature.rs` removes the feature-layer `HostAuthority`, `HostAuthorityRequest`, `HostAuthorityGrantSet`, grant/denial/install-report semantics, and per-tool authority gates. The remaining feature API is contribution/lifecycle/diagnostic oriented: tools, hooks, background tasks, services, notifications, and install diagnostics.
|
||||||
|
- No renamed equivalent authority/grant layer was introduced in `pod::feature`; remaining checks are ordinary contribution diagnostics such as duplicate tool names, undeclared tools/hooks/background tasks/services, service requirements, skipped contributions, and install errors.
|
||||||
|
- `crates/pod/src/feature/builtin/ticket.rs` no longer requests or registers feature-layer host authority. Ticket tool exposure remains controlled by `TicketFeatureConfig` resolution, backend provider/root validation, and `TicketFeatureAccess` / base-orchestration tool selection. Missing/invalid backend roots and unsupported providers still fail closed without registering tools.
|
||||||
|
- `crates/pod/src/feature/builtin/task/mod.rs` no longer describes task tools in host-authority terms; task hooks/tools still install through the normal feature registry path.
|
||||||
|
- Source grep under `crates/**/*.rs` found no remaining `HostAuthority`, `HostAuthorityRequest`, `HostAuthorityGrantSet`, `host_authority`, `with_host_authority`, `requested_host_authorities`, or `required_host_authorities` symbols/terms.
|
||||||
|
- Related Plugin/MCP planning Tickets inspected (`00001KTR81P9X`, `00001KTR82RB7`, `00001KSXRQ4G8`, `00001KT0Z4BK8`) state that Plugin/MCP permission/trust policy is not a `pod::feature` authority/grant dependency. The remaining mentions are negative boundary statements, not implementation reliance.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- `git diff --check c4465a04..HEAD` — passed.
|
||||||
|
- `cargo test -p pod feature:: --lib` — passed.
|
||||||
|
- `cargo test -p pod feature::builtin::task --lib` — passed.
|
||||||
|
- `cargo test -p ticket` — passed.
|
||||||
|
- `cargo fmt --check` — passed.
|
||||||
|
- `cargo check --workspace --all-targets` — passed.
|
||||||
|
|
||||||
|
Not run:
|
||||||
|
- `nix build .#yoi`; skipped because this review scope is source read-only with writes limited to `target/` and this Ticket record, and the change does not touch dependencies, `Cargo.lock`, packaging, or source filters.
|
||||||
|
|
||||||
|
Risks / notes:
|
||||||
|
- This intentionally removes the feature-layer authority gate rather than replacing it. Any future Plugin/MCP permission policy must be implemented in its owning layer and must not reintroduce this as a feature-layer grant API.
|
||||||
|
|
||||||
|
Decision: approved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-13T19:01:55Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Integration outcome: merged and validated.
|
||||||
|
|
||||||
|
- Implementation branch: `ticket-00001KV0SP0TY-remove-feature-hostauthority`
|
||||||
|
- Implementation commits: `5549c50d feat: remove feature host authority API`, `3faf7d7b ticket: report host authority cleanup`
|
||||||
|
- Review commit: `92e64bda ticket: approve host authority cleanup`
|
||||||
|
- Orchestrator merge commit: `297e95ef merge: remove feature host authority`
|
||||||
|
|
||||||
|
Reviewer result:
|
||||||
|
- `approve`。feature-layer `HostAuthority` / `HostAuthorityRequest` / `HostAuthorityGrantSet`、grant/denial/install-report semantics、per-tool authority gates が削除され、renamed replacement authority layer は導入されていないことを確認済み。
|
||||||
|
|
||||||
|
Orchestrator validation after merge:
|
||||||
|
- `cargo test -p pod feature:: --lib`: PASS
|
||||||
|
- `cargo test -p pod feature::builtin::task --lib`: PASS
|
||||||
|
- `cargo test -p ticket`: PASS
|
||||||
|
- `cargo fmt --check`: PASS
|
||||||
|
- `git diff --check`: PASS
|
||||||
|
- `cargo check --workspace --all-targets`: first attempt failed due to host disk full (`No space left on device`); after stopping HostAuthority child Pods and removing their child worktree/target, rerun PASS。
|
||||||
|
|
||||||
|
Cleanup performed:
|
||||||
|
- stopped `coder-00001KV0SP0TY-hostauthority` and `reviewer-00001KV0SP0TY-hostauthority`
|
||||||
|
- removed child worktree `/home/hare/Projects/yoi/.worktree/remove-feature-hostauthority`
|
||||||
|
- deleted branch `ticket-00001KV0SP0TY-remove-feature-hostauthority`
|
||||||
|
|
||||||
|
Not run:
|
||||||
|
- `nix build .#yoi`; skipped because dependencies / `Cargo.lock` / packaging/source filters were not changed and disk pressure was encountered during validation。
|
||||||
|
|
||||||
|
Next:
|
||||||
|
- mark Ticket done. Closure remains separate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-13T19:02:01Z from: inprogress to: done reason: merged_and_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation branch was reviewed, approved, merged into the Orchestrator branch as `297e95ef`, and validated in the Orchestrator worktree. Focused pod/ticket tests, formatting, diff check, and `cargo check --workspace --all-targets` passed after cleanup freed disk space. Ticket implementation work is done; closure remains separate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"id":"orch-plan-20260613-184114-1","ticket_id":"00001KV0X254D","kind":"accepted_plan","accepted_plan":{"summary":"Implement typed ticket.config orchestration branch resolution and apply it to Panel Orchestrator worktree create/reuse/restore diagnostics, preserving defaults and non-destructive safety checks.","branch":"ticket-00001KV0X254D-orchestration-branch-config","worktree":"/home/hare/Projects/yoi/.worktree/orchestration-branch-config","role_plan":"Coder writes config/resolution/TUI tests in dedicated worktree; Reviewer checks branch validation, default preservation, and non-destructive mismatch behavior."},"author":"orchestrator","at":"2026-06-13T18:41:14Z"}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Panel Orchestrator の orchestration branch 名を ticket.config.toml で設定可能にする'
|
title: 'Panel Orchestrator の orchestration branch 名を ticket.config.toml で設定可能にする'
|
||||||
state: 'queued'
|
state: 'done'
|
||||||
created_at: '2026-06-13T16:29:25Z'
|
created_at: '2026-06-13T16:29:25Z'
|
||||||
updated_at: '2026-06-13T16:33:27Z'
|
updated_at: '2026-06-14T05:05:57Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['config-schema', 'git-worktree', 'panel-orchestration']
|
risk_flags: ['config-schema', 'git-worktree', 'panel-orchestration']
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,164 @@ Intake 済み。要件・受け入れ条件・binding invariants・validation
|
||||||
Ticket を `workspace-panel` が queued にしました。
|
Ticket を `workspace-panel` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: decision author: orchestrator at: 2026-06-13T18:41:14Z -->
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Routing decision: implementation_ready
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
- Ticket は `queued` で、`.yoi/ticket.config.toml` に Panel Orchestrator の orchestration branch 名を typed config として追加する intent / requirements / acceptance criteria / invariants が具体化されている。
|
||||||
|
- `TicketRelationQuery` / `TicketOrchestrationPlanQuery` に blocker はない。
|
||||||
|
- Risk は config-schema / git-worktree / panel-orchestration だが、既存 default 維持、invalid branch の Git 操作前拒否、mismatched worktree の非破壊 diagnostic という安全境界が明記済み。
|
||||||
|
- 他 queued/inprogress との主な変更面は Panel orchestration worktree/config resolution で、E2E critical path・feature API cleanup・planning return action とは branch/worktree を分けて進められる。merge conflict が出た場合はその時点で止めて報告する。
|
||||||
|
|
||||||
|
Evidence checked:
|
||||||
|
- Ticket body / thread / artifacts。
|
||||||
|
- relation records: なし。
|
||||||
|
- orchestration plan records: なし。
|
||||||
|
- workspace state: Orchestrator worktree clean、dedicated child worktree で実装予定。
|
||||||
|
|
||||||
|
IntentPacket:
|
||||||
|
- `.yoi/ticket.config.toml` の typed config から Panel Orchestrator の resolved orchestration branch を取得し、worktree create / reuse / restore validation / diagnostics が同じ resolved value を使うようにする。
|
||||||
|
|
||||||
|
Binding decisions / invariants:
|
||||||
|
- 設定なしの default `orchestration/<workspace-orchestrator-pod-name>` は維持する。
|
||||||
|
- invalid branch は Git 操作前に拒否する。
|
||||||
|
- dirty/unknown/mismatched worktree を自動修復・削除・checkout しない。
|
||||||
|
- Ticket backend / Profile / prompt context への hidden injection ではなく明示 workspace config として扱う。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- Focused ticket config / TUI orchestration tests、`cargo fmt --check`、`git diff --check`、`target/debug/yoi ticket doctor`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-13T18:41:25Z from: queued to: inprogress reason: orchestrator_acceptance_unblocked field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket evidence、relation records、orchestration plan、workspace state を確認した。Blocking relation はなく、implementation-ready。実装 side effect の前に inprogress acceptance を記録する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-13T18:42:45Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation handoff started.
|
||||||
|
|
||||||
|
- Worktree: `/home/hare/Projects/yoi/.worktree/orchestration-branch-config`
|
||||||
|
- Branch: `ticket-00001KV0X254D-orchestration-branch-config`
|
||||||
|
- Coder Pod: `coder-00001KV0X254D-orch-branch`
|
||||||
|
- Scope: root workspace non-recursive read for launch validation only; child worktree read; writes limited to relevant TUI/Ticket/Client/Yoi crates, target, and this Ticket record。
|
||||||
|
|
||||||
|
Next: Coder implementation_report / commit / validation evidence を確認後、Reviewer を起動する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-06-13T18:59:21Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented Ticket 00001KV0X254D.
|
||||||
|
|
||||||
|
Files changed:
|
||||||
|
- `crates/ticket/src/config.rs`
|
||||||
|
- Added typed `[orchestration] branch = "..."` config support via `TicketOrchestrationConfig` and validated `GitBranchName`.
|
||||||
|
- Kept default behavior as no configured branch; documented the optional config in the generated scaffold.
|
||||||
|
- Added config validation tests for valid custom branches and invalid Git branch names.
|
||||||
|
- `crates/tui/src/multi_pod.rs`
|
||||||
|
- Resolved Panel Orchestrator worktree branch from typed ticket config, falling back to `orchestration/<workspace-orchestrator-pod-name>`.
|
||||||
|
- Reused the resolved branch for worktree creation, existing-worktree validation, restore preparation, queue handoff checks, and restore diagnostics.
|
||||||
|
- Preserved refusal behavior for dirty/unknown/mismatched worktrees; mismatches are diagnostic and do not checkout/delete/repair.
|
||||||
|
- Added TUI tests covering custom configured branch creation/reuse/restore, invalid branch rejection before worktree Git operations, and mismatched restore rejection without checkout.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo test -p ticket config --lib` — passed.
|
||||||
|
- `cargo test -p tui ensure_and_restore_use_configured_orchestration_branch --lib` — passed.
|
||||||
|
- `cargo test -p tui invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations --lib` — passed.
|
||||||
|
- `cargo test -p tui restore_rejects_mismatched_configured_orchestration_branch_without_checkout --lib` — passed.
|
||||||
|
- `cargo fmt --check` — passed.
|
||||||
|
- `git diff --check` — passed.
|
||||||
|
- `cargo build -p yoi` — passed.
|
||||||
|
- `target/debug/yoi ticket doctor` — passed.
|
||||||
|
|
||||||
|
Additional note:
|
||||||
|
- I also tried broader `cargo test -p tui orchestration --lib`; it compiled but includes the existing `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` failure, in code not touched by this change. The focused new orchestration-branch tests listed above pass.
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
- `1c54689e tui: configure orchestration branch`
|
||||||
|
|
||||||
|
Residual risks:
|
||||||
|
- Branch validation is implemented as an internal Git-ref compatible validator rather than invoking `git check-ref-format`, so it is intentionally conservative (for example, rejects spaces and full `refs/...` names) before any worktree Git operation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: hare at: 2026-06-13T19:05:48Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Approve.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `crates/ticket/src/config.rs` adds typed `[orchestration] branch = "..."` via `TicketOrchestrationConfig` and `GitBranchName`, with `deny_unknown_fields` preserved and scaffold documentation limited to the Ticket config surface. Missing config leaves `orchestration.branch_name() == None`, preserving the existing default.
|
||||||
|
- `crates/tui/src/multi_pod.rs` resolves the Panel Orchestrator branch once through `resolved_orchestration_worktree_layout`, falling back to `orchestration/<workspace-orchestrator-pod-name>`, and uses that layout for create/reuse, restore preparation/validation/diagnostics, and Queue handoff preflight/sync notices.
|
||||||
|
- Invalid configured branches fail during Ticket config loading / branch resolution before `.worktree` creation; existing dirty, unknown, wrong-branch, or unrelated-repo paths remain diagnostic-only and are not checked out, deleted, reset, or repaired.
|
||||||
|
- No hidden prompt/Profile injection or unrelated config surface drift was found; the change stays in typed Ticket config plus Panel orchestration worktree handling.
|
||||||
|
- Tests cover default resolution, configured branch creation/restore, invalid branch rejection before worktree operations, mismatched branch diagnostics without checkout, and Queue paths using the resolved branch.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- `git diff --check c4465a04..HEAD` — passed.
|
||||||
|
- `cargo fmt --check` — passed.
|
||||||
|
- `cargo test -p ticket config --lib` — passed (23 tests).
|
||||||
|
- `cargo test -p tui orchestration_worktree --lib` — passed (3 tests).
|
||||||
|
- `cargo test -p tui configured_orchestration_branch --lib` — passed (3 tests).
|
||||||
|
- `cargo test -p tui ticket_queue_action --lib` — passed (5 tests).
|
||||||
|
- `cargo test -p tui orchestration --lib` — failed only on the known unrelated `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` stale assertion; the branch/config-focused cases in that run passed.
|
||||||
|
|
||||||
|
Residual risk:
|
||||||
|
- Branch validation is an internal git-ref-compatible validator rather than an invocation of `git check-ref-format`; reviewed as acceptable and conservative for this Ticket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-14T05:05:48Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Integration outcome: merged and validated.
|
||||||
|
|
||||||
|
- Implementation branch: `ticket-00001KV0X254D-orchestration-branch-config`
|
||||||
|
- Implementation commits: `1c54689e tui: configure orchestration branch`, `fc075bc6 ticket: report orchestration branch config`
|
||||||
|
- Review commit: `9ad87dda ticket: approve orchestration branch config`
|
||||||
|
- Orchestrator merge commit: `290c4230 merge: orchestration branch config`
|
||||||
|
|
||||||
|
Reviewer result:
|
||||||
|
- `approve`。typed `[orchestration] branch` config、default preservation、invalid branch pre-Git rejection、create/reuse/restore/queue paths sharing resolved branch、non-destructive mismatch diagnostics を確認済み。
|
||||||
|
|
||||||
|
Orchestrator validation after merge:
|
||||||
|
- `cargo test -p ticket config --lib`: PASS
|
||||||
|
- `cargo test -p tui orchestration_worktree --lib`: PASS
|
||||||
|
- `cargo test -p tui configured_orchestration_branch --lib`: PASS
|
||||||
|
- `cargo test -p tui ticket_queue_action --lib`: PASS
|
||||||
|
- `cargo fmt --check`: PASS
|
||||||
|
- `git diff --check`: PASS
|
||||||
|
- `cargo build -p yoi`: PASS
|
||||||
|
- `target/debug/yoi ticket doctor`: PASS
|
||||||
|
|
||||||
|
Residual notes:
|
||||||
|
- Broad `cargo test -p tui orchestration --lib` contains a known unrelated stale assertion (`orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`) from child validation; focused branch/config tests pass.
|
||||||
|
|
||||||
|
Next:
|
||||||
|
- Mark Ticket done and clean up child coder/reviewer Pods plus implementation worktree/branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-14T05:05:57Z from: inprogress to: done reason: merged_and_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation branch was reviewed, approved, merged into the Orchestrator branch as `290c4230`, and validated in the Orchestrator worktree. Focused ticket config / TUI orchestration worktree / configured branch / queue action tests, formatting, diff check, `cargo build -p yoi`, and `yoi ticket doctor` passed. Ticket implementation work is done; closure remains separate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'E2E: close remaining critical-path gaps after panel harness'
|
title: 'E2E: close remaining critical-path gaps after panel harness'
|
||||||
state: 'inprogress'
|
state: 'done'
|
||||||
created_at: '2026-06-13T17:34:41Z'
|
created_at: '2026-06-13T17:34:41Z'
|
||||||
updated_at: '2026-06-13T17:54:19Z'
|
updated_at: '2026-06-14T05:39:03Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['e2e', 'tui', 'pty', 'quit-latency', 'mouse-input', 'rewind']
|
risk_flags: ['e2e', 'tui', 'pty', 'quit-latency', 'mouse-input', 'rewind']
|
||||||
|
|
|
||||||
|
|
@ -125,5 +125,177 @@ Next recovery options:
|
||||||
3. 継続できない場合は、作成済み worktree/branch を cleanup し、scope blocker を記録したまま人間判断待ちにする。
|
3. 継続できない場合は、作成済み worktree/branch を cleanup し、scope blocker を記録したまま人間判断待ちにする。
|
||||||
|
|
||||||
No further implementation side effects were performed after the failed spawn.
|
No further implementation side effects were performed after the failed spawn.
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-13T18:33:30Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation handoff resumed successfully after retry.
|
||||||
|
|
||||||
|
- Previous blocker was child launch validation requiring workspace root read grant.
|
||||||
|
- Retried `SpawnPod` with explicit non-recursive read grant for `/home/hare/Projects/yoi` plus child worktree read/write scopes.
|
||||||
|
- Coder Pod started: `coder-00001KV10SN02-e2e-critical`
|
||||||
|
- Worktree: `/home/hare/Projects/yoi/.worktree/e2e-critical-path`
|
||||||
|
- Branch: `ticket-00001KV10SN02-e2e-critical-path`
|
||||||
|
|
||||||
|
The root workspace grant is non-recursive and is only for runtime launch validation. Coder instructions explicitly prohibit inspecting, writing, running git, validating, merging, or cleaning up in the root/original workspace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-13T18:43:30Z from: queued to: inprogress reason: orchestrator_acceptance_recorded_after_spawn_retry field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Coder Pod `coder-00001KV10SN02-e2e-critical` has been started after SpawnPod retry with explicit root non-recursive read grant. This records the missing acceptance marker before further implementation/review side effects continue; previous handoff report already documented the retry and scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-13T19:04:40Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implementation blocked / stopped for now.
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
- Coder Pod `coder-00001KV10SN02-e2e-critical` is in an inconsistent communication state: `ReadPodOutput` reports it as stopped/unreachable, while restore/list surfaces report the socket as live/reachable, and `SendToPod` times out waiting for initial Snapshot.
|
||||||
|
- Worktree `/home/hare/Projects/yoi/.worktree/e2e-critical-path` contains uncommitted changes in `crates/tui/src/multi_pod.rs`, `crates/tui/src/single_pod.rs`, `tests/e2e/src/lib.rs`, `tests/e2e/tests/panel.rs`, and new `tests/e2e/tests/rewind.rs`.
|
||||||
|
- No Coder final report, commit, or Ticket implementation_report was received.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
- Per user instruction, stop this Ticket instead of attempting to merge incomplete/unreviewed work.
|
||||||
|
- Do not merge this branch.
|
||||||
|
- Preserve the dirty worktree for human/next-Orchestrator inspection rather than deleting it.
|
||||||
|
|
||||||
|
Recovery needed:
|
||||||
|
- Inspect dirty diff in `/home/hare/Projects/yoi/.worktree/e2e-critical-path`.
|
||||||
|
- Decide whether to salvage/commit after validation, ask Coder to continue after runtime recovery, or discard and restart from clean branch.
|
||||||
|
- Do not mark done until implementation is committed, reviewed, and validated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-14T05:13:26Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Recovery action: hand off dirty worktree to a replacement Coder.
|
||||||
|
|
||||||
|
User requested continuing with a different Coder after the prior Coder Pod stopped unexpectedly.
|
||||||
|
|
||||||
|
Current worktree state before handoff:
|
||||||
|
- Worktree: `/home/hare/Projects/yoi/.worktree/e2e-critical-path`
|
||||||
|
- Branch: `ticket-00001KV10SN02-e2e-critical-path`
|
||||||
|
- Dirty files from prior Coder attempt:
|
||||||
|
- `crates/tui/src/multi_pod.rs`
|
||||||
|
- `crates/tui/src/single_pod.rs`
|
||||||
|
- `tests/e2e/src/lib.rs`
|
||||||
|
- `tests/e2e/tests/panel.rs`
|
||||||
|
- `tests/e2e/tests/rewind.rs` (new)
|
||||||
|
- `git diff --check` currently reports no whitespace/check errors.
|
||||||
|
|
||||||
|
Replacement Coder task:
|
||||||
|
- Inspect the dirty diff, decide whether it is salvageable, then either finish/commit/report it or stop with a concrete blocker.
|
||||||
|
- Do not merge or clean up; Orchestrator will handle review/merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-06-14T05:24:37Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented critical-path TUI E2E coverage in commit b9f49eee.
|
||||||
|
|
||||||
|
Files changed:
|
||||||
|
- crates/tui/src/multi_pod.rs: panel mouse wheel events now move selection and emit e2e observer diagnostics without enabling full drag-motion capture.
|
||||||
|
- crates/tui/src/single_pod.rs: e2e-test-only canned single-Pod rewind fixture path for real TUI/PTTY driving; preserves production/non-production boundary.
|
||||||
|
- tests/e2e/Cargo.toml: disables auto-discovered integration tests and registers panel/rewind only behind the e2e feature.
|
||||||
|
- tests/e2e/src/lib.rs: harness support for rewind fixture env isolation, wheel input, event waiting, and forbidden ?1002h/?1003h capture detection.
|
||||||
|
- tests/e2e/tests/panel.rs: adds PTY wheel regression coverage and drag-capture failure assertion while keeping existing panel cases.
|
||||||
|
- tests/e2e/tests/rewind.rs: adds single-Pod rewind picker/apply/duplicate-Enter regression.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- cargo fmt --check
|
||||||
|
- git diff --check
|
||||||
|
- cargo test -p yoi-e2e --no-run
|
||||||
|
- cargo build -p yoi
|
||||||
|
- cargo test -p yoi-e2e --features e2e
|
||||||
|
- cargo test -p tui --lib single_pod::tests::rewind
|
||||||
|
- cargo test -p tui --lib mouse
|
||||||
|
- nix build .#yoi
|
||||||
|
|
||||||
|
Additional note:
|
||||||
|
- Full cargo test -p tui was also attempted and failed in pre-existing/unrelated tests: multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace and spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}. Focused changed-area tui tests passed.
|
||||||
|
|
||||||
|
Residual risks:
|
||||||
|
- Rewind E2E uses an e2e-test-only canned in-process fixture instead of a provider/socket-backed Pod, intentionally avoiding credentials/network while still exercising the real single-Pod TUI/PTTY path.
|
||||||
|
- nix build emitted the expected dirty-tree warning before succeeding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: hare at: 2026-06-14T05:33:43Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
APPROVE.
|
||||||
|
|
||||||
|
Review evidence:
|
||||||
|
- Diff base: inspected `git diff orchestration/yoi-orchestrator..HEAD`; changed files are limited to this Ticket record plus intended TUI/E2E files (`crates/tui/src/{multi_pod.rs,single_pod.rs}`, `tests/e2e/{Cargo.toml,src/lib.rs,tests/panel.rs,tests/rewind.rs}`). No unrelated Ticket records or broad code churn were present.
|
||||||
|
- Existing Panel E2E remains opt-in: `tests/e2e/Cargo.toml` uses `autotests = false`; `panel` and `rewind` integration tests have `required-features = ["e2e"]`. `cargo test -p yoi-e2e --no-run` compiled only the library test, while `cargo test -p yoi-e2e --features e2e` ran the isolated panel/rewind cases.
|
||||||
|
- Wheel PTY coverage is meaningful: the panel test drives real PTY SGR wheel input at a row coordinate derived from `rows_rendered`, then observes `mouse_wheel`, `selection_changed`, and refreshed `rows_rendered` events. This exercises terminal input delivery and row/list selection behavior rather than direct state mutation.
|
||||||
|
- Mouse capture assertions cover the intended invariant: the TUI now enables normal tracking + SGR through the local `EnableWheelMouseCapture` command, and tests assert mouse capture is enabled while rejecting forbidden full drag-motion modes. Observed PTY artifacts from the reviewer run contained `?1000h`/`?1006h` once and `?1002h`/`?1003h` zero times for panel and rewind runs.
|
||||||
|
- Rewind E2E uses a real single-Pod TUI/PTTY surface with an `e2e-test`-only fixture. The test sends `Ctrl+R`, `Enter`, and repeated `Enter`; observed events show picker open, exactly one rewind submit, duplicate Enter suppression while pending, and `rewind_applied` with restored composer text. The PTY output artifact also contained the restored text (`revise the plan`), so this covers visible composer restoration without Esc/restart/restore.
|
||||||
|
- Credential/network isolation is preserved: the E2E harness uses `env_clear`, fixture HOME/XDG runtime/data/config/state, explicit runtime binary override, and default-deny provider credential policy. The rewind fixture is only compiled/activated behind the `e2e-test` feature and `YOI_TUI_TEST_REWIND_FIXTURE`; normal `cargo build -p yoi` passed without that fixture.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- `cargo fmt --check` — pass
|
||||||
|
- `git diff --check orchestration/yoi-orchestrator..HEAD` — pass
|
||||||
|
- `cargo test -p yoi-e2e --no-run` — pass
|
||||||
|
- `cargo build -p yoi` — pass
|
||||||
|
- `cargo test -p yoi-e2e --features e2e` — pass (1 lib env-policy test, 3 panel tests, 1 rewind test)
|
||||||
|
- `cargo test -p tui --lib single_pod::tests::rewind` — pass (5 tests)
|
||||||
|
- `cargo test -p tui --lib mouse` — pass (4 tests)
|
||||||
|
- `cargo test -p tui --lib` — fails in known unrelated tests already noted by implementation report: `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` and `spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}`; 327 other tests passed, including the changed-area mouse/rewind tests.
|
||||||
|
- `nix build .#yoi` — not run by reviewer because this review grant allows writes only under `target/` and this Ticket record; a normal nix build would write outside that boundary (store/result link). Coder's implementation report recorded a successful nix build with the expected dirty-tree warning.
|
||||||
|
|
||||||
|
Residual risk:
|
||||||
|
- The rewind E2E intentionally uses an in-process canned rewind fixture instead of a provider/socket-backed Pod to avoid credentials and network. This is acceptable for the Ticket's critical TUI/PTTY regression focus, but it is not full provider integration coverage.
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: orchestrator at: 2026-06-14T05:38:52Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Integration outcome: merged and validated.
|
||||||
|
|
||||||
|
- Implementation branch: `ticket-00001KV10SN02-e2e-critical-path`
|
||||||
|
- Implementation commits: `b9f49eee test: cover critical tui e2e paths`, `3de938b7 ticket: report e2e critical implementation`
|
||||||
|
- Sync commit: `b6c6fc04 merge: sync e2e critical branch`
|
||||||
|
- Review commit: `581cb202 ticket: approve e2e critical path`
|
||||||
|
- Orchestrator merge commit: `059b1fd4 merge: e2e critical path`
|
||||||
|
|
||||||
|
Reviewer result:
|
||||||
|
- `approve`。Panel wheel PTY coverage, forbidden drag-capture checks, single-Pod rewind PTY E2E with canned e2e-test fixture, no real provider/network calls, and existing tmp/env/runtime isolation preservation were reviewed.
|
||||||
|
|
||||||
|
Orchestrator validation after merge:
|
||||||
|
- `cargo fmt --check`: PASS
|
||||||
|
- `git diff --check`: PASS
|
||||||
|
- `cargo test -p yoi-e2e --no-run`: PASS
|
||||||
|
- `cargo build -p yoi`: PASS
|
||||||
|
- `cargo test -p yoi-e2e --features e2e`: PASS
|
||||||
|
- `cargo test -p tui --lib single_pod::tests::rewind`: PASS
|
||||||
|
- `cargo test -p tui --lib mouse`: PASS
|
||||||
|
- `nix build .#yoi`: PASS
|
||||||
|
|
||||||
|
Residual notes:
|
||||||
|
- Full `cargo test -p tui --lib` was not used as merge gate because reviewer/coder both observed known unrelated failures in `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` and `spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}`.
|
||||||
|
- Rewind E2E intentionally uses e2e-test-only canned fixture rather than provider/socket-backed Pod to avoid credentials/network while covering the TUI/PTTY regression path.
|
||||||
|
|
||||||
|
Next:
|
||||||
|
- Mark Ticket done and clean up replacement Coder / Reviewer Pods plus implementation worktree/branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: orchestrator at: 2026-06-14T05:39:03Z from: inprogress to: done reason: merged_and_validated field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Implementation branch was reviewed, approved, merged into the Orchestrator branch as `059b1fd4`, and validated in the Orchestrator worktree. Focused E2E, TUI rewind/mouse tests, formatting, diff check, `cargo build -p yoi`, and `nix build .#yoi` passed. Ticket implementation work is done; closure remains separate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Feature contribution registry for Pod-hosted builtin/plugin modules.
|
//! Feature contribution registry for Pod-hosted builtin/plugin modules.
|
||||||
//!
|
//!
|
||||||
//! This module defines the Pod-side feature boundary used to collect
|
//! This module defines the Pod-side feature boundary used to collect
|
||||||
//! descriptor metadata, host authority requests, tool contributions, safe hook
|
//! descriptor metadata, tool contributions, safe hook contributions, background
|
||||||
//! contributions, background task declarations, and service declarations before
|
//! task declarations, and service declarations before
|
||||||
//! installing them into the existing Worker/HookRegistry host surfaces.
|
//! installing them into the existing Worker/HookRegistry host surfaces.
|
||||||
//!
|
//!
|
||||||
//! The first implementation slice is intentionally host-mediated and
|
//! The first implementation slice is intentionally host-mediated and
|
||||||
|
|
@ -69,26 +69,6 @@ pub enum FeatureRuntimeKind {
|
||||||
ExternalPlugin,
|
ExternalPlugin,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host authority requested by a feature for host-mediated operations that can
|
|
||||||
/// cross sandbox or model-context boundaries.
|
|
||||||
///
|
|
||||||
/// Contribution declarations such as tools, hooks, background tasks, and
|
|
||||||
/// services are descriptor/package-approved host-visible contributions, not
|
|
||||||
/// host authorities. Host authority grants are additive and do not replace
|
|
||||||
/// manifest/tool permission checks.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum HostAuthority {
|
|
||||||
Filesystem,
|
|
||||||
Network,
|
|
||||||
SecretRef { id: String },
|
|
||||||
ModelNotification,
|
|
||||||
PodManagement,
|
|
||||||
StateStore { name: String },
|
|
||||||
TicketBackend { root: String },
|
|
||||||
ServiceAccess { service: ServiceId },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A safe hook contribution point exposed to feature modules.
|
/// A safe hook contribution point exposed to feature modules.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|
@ -99,82 +79,6 @@ pub enum FeatureHookPoint {
|
||||||
TurnEnd,
|
TurnEnd,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host authority request declared by a feature descriptor.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct HostAuthorityRequest {
|
|
||||||
pub authority: HostAuthority,
|
|
||||||
pub required: bool,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostAuthorityRequest {
|
|
||||||
pub fn required(authority: HostAuthority, reason: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
authority,
|
|
||||||
required: true,
|
|
||||||
reason: reason.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn optional(authority: HostAuthority, reason: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
authority,
|
|
||||||
required: false,
|
|
||||||
reason: reason.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host authority grants resolved by the host for one feature installation.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct HostAuthorityGrantSet {
|
|
||||||
granted: HashSet<HostAuthority>,
|
|
||||||
denied: Vec<HostAuthorityDenial>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostAuthorityGrantSet {
|
|
||||||
pub fn grant_all(requests: &[HostAuthorityRequest]) -> Self {
|
|
||||||
Self {
|
|
||||||
granted: requests
|
|
||||||
.iter()
|
|
||||||
.map(|request| request.authority.clone())
|
|
||||||
.collect(),
|
|
||||||
denied: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn empty() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn contains(&self, authority: &HostAuthority) -> bool {
|
|
||||||
self.granted.contains(authority)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn denied(&self) -> &[HostAuthorityDenial] {
|
|
||||||
&self.denied
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn grant(&mut self, authority: HostAuthority) {
|
|
||||||
self.granted.insert(authority);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deny(&mut self, authority: HostAuthority, reason: impl Into<String>) {
|
|
||||||
self.granted.remove(&authority);
|
|
||||||
self.denied.push(HostAuthorityDenial {
|
|
||||||
authority,
|
|
||||||
reason: reason.into(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host-side denial of a requested feature host authority.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct HostAuthorityDenial {
|
|
||||||
pub authority: HostAuthority,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serializable declaration of a tool contribution. The executable factory is
|
/// Serializable declaration of a tool contribution. The executable factory is
|
||||||
/// carried by [`ToolContribution`] during installation.
|
/// carried by [`ToolContribution`] during installation.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -192,12 +96,10 @@ impl ToolDeclaration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executable tool contribution wrapper. Host-authority requirements are optional
|
/// Executable tool contribution wrapper.
|
||||||
/// per-tool gates for privileged host APIs, not permission to contribute a tool.
|
|
||||||
pub struct ToolContribution {
|
pub struct ToolContribution {
|
||||||
name: String,
|
name: String,
|
||||||
definition: ToolDefinition,
|
definition: ToolDefinition,
|
||||||
required_host_authorities: Vec<HostAuthority>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolContribution {
|
impl ToolContribution {
|
||||||
|
|
@ -205,18 +107,9 @@ impl ToolContribution {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
definition,
|
definition,
|
||||||
required_host_authorities: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_required_host_authorities(
|
|
||||||
mut self,
|
|
||||||
required_host_authorities: Vec<HostAuthority>,
|
|
||||||
) -> Self {
|
|
||||||
self.required_host_authorities = required_host_authorities;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +303,6 @@ pub struct FeatureDescriptor {
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub requested_host_authorities: Vec<HostAuthorityRequest>,
|
|
||||||
pub tools: Vec<ToolDeclaration>,
|
pub tools: Vec<ToolDeclaration>,
|
||||||
pub hooks: Vec<HookDeclaration>,
|
pub hooks: Vec<HookDeclaration>,
|
||||||
pub background_tasks: Vec<BackgroundTaskDeclaration>,
|
pub background_tasks: Vec<BackgroundTaskDeclaration>,
|
||||||
|
|
@ -426,7 +318,6 @@ impl FeatureDescriptor {
|
||||||
display_name: display_name.into(),
|
display_name: display_name.into(),
|
||||||
version: env!("CARGO_PKG_VERSION").into(),
|
version: env!("CARGO_PKG_VERSION").into(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
requested_host_authorities: Vec::new(),
|
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
hooks: Vec::new(),
|
hooks: Vec::new(),
|
||||||
background_tasks: Vec::new(),
|
background_tasks: Vec::new(),
|
||||||
|
|
@ -440,11 +331,6 @@ impl FeatureDescriptor {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_host_authority(mut self, request: HostAuthorityRequest) -> Self {
|
|
||||||
self.requested_host_authorities.push(request);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_tool(mut self, tool: ToolDeclaration) -> Self {
|
pub fn with_tool(mut self, tool: ToolDeclaration) -> Self {
|
||||||
self.tools.push(tool);
|
self.tools.push(tool);
|
||||||
self
|
self
|
||||||
|
|
@ -543,7 +429,6 @@ pub struct FeatureInstallReport {
|
||||||
pub feature_id: FeatureId,
|
pub feature_id: FeatureId,
|
||||||
pub runtime: FeatureRuntimeKind,
|
pub runtime: FeatureRuntimeKind,
|
||||||
pub installed: bool,
|
pub installed: bool,
|
||||||
pub host_authority_grants: HostAuthorityGrantSet,
|
|
||||||
pub installed_tools: Vec<String>,
|
pub installed_tools: Vec<String>,
|
||||||
pub installed_hooks: Vec<HookDeclaration>,
|
pub installed_hooks: Vec<HookDeclaration>,
|
||||||
pub declared_background_tasks: Vec<BackgroundTaskDeclaration>,
|
pub declared_background_tasks: Vec<BackgroundTaskDeclaration>,
|
||||||
|
|
@ -554,12 +439,11 @@ pub struct FeatureInstallReport {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureInstallReport {
|
impl FeatureInstallReport {
|
||||||
fn new(descriptor: &FeatureDescriptor, host_authority_grants: HostAuthorityGrantSet) -> Self {
|
fn new(descriptor: &FeatureDescriptor) -> Self {
|
||||||
Self {
|
Self {
|
||||||
feature_id: descriptor.id.clone(),
|
feature_id: descriptor.id.clone(),
|
||||||
runtime: descriptor.runtime.clone(),
|
runtime: descriptor.runtime.clone(),
|
||||||
installed: false,
|
installed: false,
|
||||||
host_authority_grants,
|
|
||||||
installed_tools: Vec::new(),
|
installed_tools: Vec::new(),
|
||||||
installed_hooks: Vec::new(),
|
installed_hooks: Vec::new(),
|
||||||
declared_background_tasks: Vec::new(),
|
declared_background_tasks: Vec::new(),
|
||||||
|
|
@ -653,38 +537,14 @@ fn reject_undeclared_contribution(
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_host_authority(
|
|
||||||
host_authority_grants: &HostAuthorityGrantSet,
|
|
||||||
report: &mut FeatureInstallReport,
|
|
||||||
kind: FeatureContributionKind,
|
|
||||||
name: impl Into<String>,
|
|
||||||
authority: &HostAuthority,
|
|
||||||
) -> Result<(), FeatureInstallError> {
|
|
||||||
if host_authority_grants.contains(authority) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let reason = format!("required host authority was not granted: {authority:?}");
|
|
||||||
report.mark_skipped(kind, name, reason.clone());
|
|
||||||
Err(FeatureInstallError::HostAuthorityDenied(reason))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Model-visible durable notification sink skeleton. The first slice exposes
|
/// Model-visible durable notification sink skeleton. The first slice exposes
|
||||||
/// the boundary without implementing a new event channel.
|
/// the boundary without implementing a new event channel.
|
||||||
pub struct FeatureNotificationSink<'a> {
|
pub struct FeatureNotificationSink<'a> {
|
||||||
host_authority_grants: &'a HostAuthorityGrantSet,
|
|
||||||
report: &'a mut FeatureInstallReport,
|
report: &'a mut FeatureInstallReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureNotificationSink<'_> {
|
impl FeatureNotificationSink<'_> {
|
||||||
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
|
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
|
||||||
require_host_authority(
|
|
||||||
self.host_authority_grants,
|
|
||||||
self.report,
|
|
||||||
FeatureContributionKind::Notification,
|
|
||||||
"notify_model",
|
|
||||||
&HostAuthority::ModelNotification,
|
|
||||||
)?;
|
|
||||||
let message = message.into();
|
let message = message.into();
|
||||||
self.report.diagnostics.push(FeatureDiagnostic::warning(format!(
|
self.report.diagnostics.push(FeatureDiagnostic::warning(format!(
|
||||||
"model notification requested during feature installation but no durable Notify host is attached: {message}"
|
"model notification requested during feature installation but no durable Notify host is attached: {message}"
|
||||||
|
|
@ -744,7 +604,6 @@ impl FeatureDiagnosticSink<'_> {
|
||||||
pub struct ToolContributionRegistrar<'a> {
|
pub struct ToolContributionRegistrar<'a> {
|
||||||
feature_id: &'a FeatureId,
|
feature_id: &'a FeatureId,
|
||||||
declarations: &'a FeatureContributionDeclarations,
|
declarations: &'a FeatureContributionDeclarations,
|
||||||
host_authority_grants: &'a HostAuthorityGrantSet,
|
|
||||||
pending_tools: &'a mut Vec<ToolDefinition>,
|
pending_tools: &'a mut Vec<ToolDefinition>,
|
||||||
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
||||||
report: &'a mut FeatureInstallReport,
|
report: &'a mut FeatureInstallReport,
|
||||||
|
|
@ -776,16 +635,6 @@ impl ToolContributionRegistrar<'_> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
for authority in &contribution.required_host_authorities {
|
|
||||||
require_host_authority(
|
|
||||||
self.host_authority_grants,
|
|
||||||
self.report,
|
|
||||||
FeatureContributionKind::Tool,
|
|
||||||
model_visible_name.clone(),
|
|
||||||
authority,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(first) = self.installed_tool_names.get(&model_visible_name) {
|
if let Some(first) = self.installed_tool_names.get(&model_visible_name) {
|
||||||
let error = FeatureInstallError::DuplicateToolName {
|
let error = FeatureInstallError::DuplicateToolName {
|
||||||
tool: model_visible_name.clone(),
|
tool: model_visible_name.clone(),
|
||||||
|
|
@ -951,7 +800,6 @@ impl FeatureServiceRegistrar<'_> {
|
||||||
pub struct FeatureInstallContext<'a> {
|
pub struct FeatureInstallContext<'a> {
|
||||||
feature_id: &'a FeatureId,
|
feature_id: &'a FeatureId,
|
||||||
declarations: &'a FeatureContributionDeclarations,
|
declarations: &'a FeatureContributionDeclarations,
|
||||||
host_authority_grants: &'a HostAuthorityGrantSet,
|
|
||||||
pending_tools: &'a mut Vec<ToolDefinition>,
|
pending_tools: &'a mut Vec<ToolDefinition>,
|
||||||
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
||||||
hook_builder: &'a mut HookRegistryBuilder,
|
hook_builder: &'a mut HookRegistryBuilder,
|
||||||
|
|
@ -964,15 +812,10 @@ impl FeatureInstallContext<'_> {
|
||||||
self.feature_id
|
self.feature_id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn host_authority_grants(&self) -> &HostAuthorityGrantSet {
|
|
||||||
self.host_authority_grants
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tools(&mut self) -> ToolContributionRegistrar<'_> {
|
pub fn tools(&mut self) -> ToolContributionRegistrar<'_> {
|
||||||
ToolContributionRegistrar {
|
ToolContributionRegistrar {
|
||||||
feature_id: self.feature_id,
|
feature_id: self.feature_id,
|
||||||
declarations: self.declarations,
|
declarations: self.declarations,
|
||||||
host_authority_grants: self.host_authority_grants,
|
|
||||||
pending_tools: self.pending_tools,
|
pending_tools: self.pending_tools,
|
||||||
installed_tool_names: self.installed_tool_names,
|
installed_tool_names: self.installed_tool_names,
|
||||||
report: self.report,
|
report: self.report,
|
||||||
|
|
@ -1007,7 +850,6 @@ impl FeatureInstallContext<'_> {
|
||||||
|
|
||||||
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
|
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
|
||||||
FeatureNotificationSink {
|
FeatureNotificationSink {
|
||||||
host_authority_grants: self.host_authority_grants,
|
|
||||||
report: self.report,
|
report: self.report,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1107,10 +949,8 @@ impl FeatureRegistryBuilder {
|
||||||
let mut seen_features = HashSet::new();
|
let mut seen_features = HashSet::new();
|
||||||
|
|
||||||
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
|
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
|
||||||
let host_authority_grants =
|
|
||||||
HostAuthorityGrantSet::grant_all(&descriptor.requested_host_authorities);
|
|
||||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||||
let mut report = FeatureInstallReport::new(&descriptor, host_authority_grants.clone());
|
let mut report = FeatureInstallReport::new(&descriptor);
|
||||||
|
|
||||||
if !seen_features.insert(descriptor.id.clone()) {
|
if !seen_features.insert(descriptor.id.clone()) {
|
||||||
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
||||||
|
|
@ -1126,13 +966,6 @@ impl FeatureRegistryBuilder {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for authority in host_authority_grants.denied() {
|
|
||||||
report.diagnostics.push(FeatureDiagnostic::warning(format!(
|
|
||||||
"host authority denied: {:?}: {}",
|
|
||||||
authority.authority, authority.reason
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut required_service_failed = false;
|
let mut required_service_failed = false;
|
||||||
for requirement in descriptor.requires_services.iter().cloned() {
|
for requirement in descriptor.requires_services.iter().cloned() {
|
||||||
if service_registry.provides(&requirement.id) {
|
if service_registry.provides(&requirement.id) {
|
||||||
|
|
@ -1192,7 +1025,6 @@ impl FeatureRegistryBuilder {
|
||||||
let mut context = FeatureInstallContext {
|
let mut context = FeatureInstallContext {
|
||||||
feature_id: &descriptor.id,
|
feature_id: &descriptor.id,
|
||||||
declarations: &declarations,
|
declarations: &declarations,
|
||||||
host_authority_grants: &host_authority_grants,
|
|
||||||
pending_tools,
|
pending_tools,
|
||||||
installed_tool_names: &mut installed_tool_names,
|
installed_tool_names: &mut installed_tool_names,
|
||||||
hook_builder,
|
hook_builder,
|
||||||
|
|
@ -1256,8 +1088,6 @@ pub enum FeatureInstallError {
|
||||||
first_feature: String,
|
first_feature: String,
|
||||||
duplicate_feature: String,
|
duplicate_feature: String,
|
||||||
},
|
},
|
||||||
#[error("feature host authority denied: {0}")]
|
|
||||||
HostAuthorityDenied(String),
|
|
||||||
#[error("feature install failed: {0}")]
|
#[error("feature install failed: {0}")]
|
||||||
Install(String),
|
Install(String),
|
||||||
}
|
}
|
||||||
|
|
@ -1335,7 +1165,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_contributions_and_empty_host_authority_grants_are_recorded() {
|
fn descriptor_contributions_are_recorded() {
|
||||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||||
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"))
|
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"))
|
||||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
||||||
|
|
@ -1358,7 +1188,6 @@ mod tests {
|
||||||
assert!(feature_report.installed);
|
assert!(feature_report.installed);
|
||||||
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
|
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
|
||||||
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
|
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
|
||||||
assert!(feature_report.host_authority_grants.denied().is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1421,79 +1250,6 @@ mod tests {
|
||||||
assert_eq!(report.reports[0].skipped[0].name, "Actual");
|
assert_eq!(report.reports[0].skipped[0].name, "Actual");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_host_authority_requirements_use_host_authority_grants_not_contribution_declarations() {
|
|
||||||
struct HostAuthorityToolFeature {
|
|
||||||
descriptor: FeatureDescriptor,
|
|
||||||
required_host_authorities: Vec<HostAuthority>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FeatureModule for HostAuthorityToolFeature {
|
|
||||||
fn descriptor(&self) -> FeatureDescriptor {
|
|
||||||
self.descriptor.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install(
|
|
||||||
&self,
|
|
||||||
context: &mut FeatureInstallContext<'_>,
|
|
||||||
) -> Result<(), FeatureInstallError> {
|
|
||||||
context.tools().register(
|
|
||||||
ToolContribution::new("NetworkTool", dummy_tool("NetworkTool"))
|
|
||||||
.with_required_host_authorities(self.required_host_authorities.clone()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut hook_builder = HookRegistryBuilder::default();
|
|
||||||
let mut pending_tools = Vec::new();
|
|
||||||
let missing_grant = FeatureDescriptor::builtin("missing-host-authority", "Missing")
|
|
||||||
.with_tool(ToolDeclaration::new("NetworkTool", "network host API tool"));
|
|
||||||
let missing_report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(HostAuthorityToolFeature {
|
|
||||||
descriptor: missing_grant,
|
|
||||||
required_host_authorities: vec![HostAuthority::Network],
|
|
||||||
})
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
|
||||||
|
|
||||||
assert!(pending_tools.is_empty());
|
|
||||||
assert!(!missing_report.reports[0].installed);
|
|
||||||
assert!(
|
|
||||||
missing_report.reports[0]
|
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| {
|
|
||||||
diagnostic
|
|
||||||
.message
|
|
||||||
.contains("required host authority was not granted")
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
missing_report.reports[0].skipped[0].kind,
|
|
||||||
FeatureContributionKind::Tool
|
|
||||||
);
|
|
||||||
|
|
||||||
let granted = FeatureDescriptor::builtin("granted-host-authority", "Granted")
|
|
||||||
.with_host_authority(HostAuthorityRequest::required(
|
|
||||||
HostAuthority::Network,
|
|
||||||
"uses a host network API",
|
|
||||||
))
|
|
||||||
.with_tool(ToolDeclaration::new("NetworkTool", "network host API tool"));
|
|
||||||
let granted_report = FeatureRegistryBuilder::new()
|
|
||||||
.with_module(HostAuthorityToolFeature {
|
|
||||||
descriptor: granted,
|
|
||||||
required_host_authorities: vec![HostAuthority::Network],
|
|
||||||
})
|
|
||||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
|
||||||
|
|
||||||
assert!(granted_report.reports[0].installed);
|
|
||||||
assert!(
|
|
||||||
granted_report.reports[0]
|
|
||||||
.host_authority_grants
|
|
||||||
.contains(&HostAuthority::Network)
|
|
||||||
);
|
|
||||||
assert_eq!(pending_tools.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stateful_tool_definition_is_materialized_once_for_report_and_worker() {
|
fn stateful_tool_definition_is_materialized_once_for_report_and_worker() {
|
||||||
struct StatefulToolFeature {
|
struct StatefulToolFeature {
|
||||||
|
|
@ -1790,7 +1546,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn background_task_declaration_is_not_host_authority_gated() {
|
fn background_task_declaration_is_descriptor_contribution() {
|
||||||
let descriptor = FeatureDescriptor::builtin("background", "Background")
|
let descriptor = FeatureDescriptor::builtin("background", "Background")
|
||||||
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
||||||
"declared-task",
|
"declared-task",
|
||||||
|
|
@ -1811,7 +1567,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn service_provider_declaration_is_not_host_authority_gated() {
|
fn service_provider_declaration_is_descriptor_contribution() {
|
||||||
let service = ServiceId::builtin("declared-service");
|
let service = ServiceId::builtin("declared-service");
|
||||||
let descriptor = FeatureDescriptor::builtin("service", "Service").with_provided_service(
|
let descriptor = FeatureDescriptor::builtin("service", "Service").with_provided_service(
|
||||||
ServiceDeclaration::new(service.clone(), "1", "descriptor contribution"),
|
ServiceDeclaration::new(service.clone(), "1", "descriptor contribution"),
|
||||||
|
|
@ -1829,7 +1585,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks_and_no_host_authorities() {
|
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks() {
|
||||||
let descriptor = builtin::task_tools_feature().descriptor();
|
let descriptor = builtin::task_tools_feature().descriptor();
|
||||||
let tool_names: Vec<_> = descriptor
|
let tool_names: Vec<_> = descriptor
|
||||||
.tools
|
.tools
|
||||||
|
|
@ -1845,7 +1601,6 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(descriptor.id.as_str(), "builtin:task-tools");
|
assert_eq!(descriptor.id.as_str(), "builtin:task-tools");
|
||||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||||
assert!(descriptor.requested_host_authorities.is_empty());
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hook_points,
|
hook_points,
|
||||||
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
|
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
|
||||||
|
|
@ -1860,7 +1615,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_internal_task_feature_installs_declared_tools_without_host_authorities() {
|
fn builtin_internal_task_feature_installs_declared_tools() {
|
||||||
let mut hook_builder = HookRegistryBuilder::default();
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
let mut builder = FeatureRegistryBuilder::new();
|
let mut builder = FeatureRegistryBuilder::new();
|
||||||
|
|
@ -1882,10 +1637,6 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(report.reports.len(), 1);
|
assert_eq!(report.reports.len(), 1);
|
||||||
assert!(report.reports[0].installed);
|
assert!(report.reports[0].installed);
|
||||||
assert_eq!(
|
|
||||||
report.reports[0].host_authority_grants,
|
|
||||||
HostAuthorityGrantSet::empty()
|
|
||||||
);
|
|
||||||
assert!(report.reports[0].skipped.is_empty());
|
assert!(report.reports[0].skipped.is_empty());
|
||||||
assert!(report.reports[0].diagnostics.is_empty());
|
assert!(report.reports[0].diagnostics.is_empty());
|
||||||
assert_eq!(report.reports[0].installed_hooks.len(), 2);
|
assert_eq!(report.reports[0].installed_hooks.len(), 2);
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,8 @@ const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
|
||||||
///
|
///
|
||||||
/// The returned module contributes `TaskCreate`, `TaskUpdate`, `TaskGet`, and
|
/// The returned module contributes `TaskCreate`, `TaskUpdate`, `TaskGet`, and
|
||||||
/// `TaskList` through descriptor-approved tool registration, plus built-in hooks
|
/// `TaskList` through descriptor-approved tool registration, plus built-in hooks
|
||||||
/// that maintain Task-reminder state. It does not request sandbox/external-plugin
|
/// that maintain Task-reminder state. Normal ToolRegistry and PreToolCall
|
||||||
/// host authorities; normal ToolRegistry and PreToolCall permission policy still
|
/// permission policy still applies at call time.
|
||||||
/// applies at call time.
|
|
||||||
pub fn task_tools_feature() -> TaskFeature {
|
pub fn task_tools_feature() -> TaskFeature {
|
||||||
TaskFeature::new()
|
TaskFeature::new()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,13 @@ use ticket::{
|
||||||
|
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||||
FeatureModule, HostAuthority, HostAuthorityRequest, ToolContribution, ToolDeclaration,
|
FeatureModule, ToolContribution, ToolDeclaration,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_ID: &str = "ticket";
|
const FEATURE_ID: &str = "ticket";
|
||||||
const FEATURE_NAME: &str = "Ticket tools";
|
const FEATURE_NAME: &str = "Ticket tools";
|
||||||
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
||||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||||
const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for typed work-item operations without generic filesystem write authority.";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum TicketFeatureAccess {
|
pub enum TicketFeatureAccess {
|
||||||
|
|
@ -150,12 +149,6 @@ impl TicketFeature {
|
||||||
names
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authority(&self) -> HostAuthority {
|
|
||||||
HostAuthority::TicketBackend {
|
|
||||||
root: self.backend_root.display().to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
||||||
let root = self
|
let root = self
|
||||||
.backend_root
|
.backend_root
|
||||||
|
|
@ -171,11 +164,7 @@ impl TicketFeature {
|
||||||
impl FeatureModule for TicketFeature {
|
impl FeatureModule for TicketFeature {
|
||||||
fn descriptor(&self) -> FeatureDescriptor {
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||||
.with_description(FEATURE_DESCRIPTION)
|
.with_description(FEATURE_DESCRIPTION);
|
||||||
.with_host_authority(HostAuthorityRequest::required(
|
|
||||||
self.authority(),
|
|
||||||
AUTHORITY_REASON,
|
|
||||||
));
|
|
||||||
let enabled_tool_names = self.enabled_tool_names();
|
let enabled_tool_names = self.enabled_tool_names();
|
||||||
for name in &enabled_tool_names {
|
for name in &enabled_tool_names {
|
||||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||||
|
|
@ -207,7 +196,6 @@ impl FeatureModule for TicketFeature {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let authority = self.authority();
|
|
||||||
let backend = LocalTicketBackend::new(usable_root)
|
let backend = LocalTicketBackend::new(usable_root)
|
||||||
.with_record_language(self.record_language.as_deref());
|
.with_record_language(self.record_language.as_deref());
|
||||||
let allowed_tool_names = self.enabled_tool_names();
|
let allowed_tool_names = self.enabled_tool_names();
|
||||||
|
|
@ -221,10 +209,7 @@ impl FeatureModule for TicketFeature {
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tools.register(
|
tools.register(ToolContribution::new(name, definition))?;
|
||||||
ToolContribution::new(name, definition)
|
|
||||||
.with_required_host_authorities(vec![authority.clone()]),
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -284,7 +269,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_declares_ticket_tools_and_backend_authority() {
|
fn descriptor_declares_ticket_tools() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let feature = ticket_tools_feature(temp.path());
|
let feature = ticket_tools_feature(temp.path());
|
||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
|
|
@ -299,11 +284,6 @@ mod tests {
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
TICKET_TOOL_NAMES
|
TICKET_TOOL_NAMES
|
||||||
);
|
);
|
||||||
assert_eq!(descriptor.requested_host_authorities.len(), 1);
|
|
||||||
assert!(matches!(
|
|
||||||
descriptor.requested_host_authorities[0].authority,
|
|
||||||
HostAuthority::TicketBackend { .. }
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -321,7 +301,6 @@ mod tests {
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
TICKET_READ_ONLY_TOOL_NAMES
|
TICKET_READ_ONLY_TOOL_NAMES
|
||||||
);
|
);
|
||||||
assert_eq!(descriptor.requested_host_authorities.len(), 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ pub fn ticket_config_scaffold() -> String {
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n",
|
"\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n",
|
||||||
);
|
);
|
||||||
|
out.push_str(
|
||||||
|
"\n# Optional Panel Orchestrator worktree branch. When unset, Panel uses orchestration/<workspace-orchestrator-pod-name>.\n# [orchestration]\n# branch = \"orchestration/<workspace-orchestrator-pod-name>\"\n",
|
||||||
|
);
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
||||||
|
|
@ -67,15 +70,110 @@ pub enum TicketConfigError {
|
||||||
pub struct TicketConfig {
|
pub struct TicketConfig {
|
||||||
pub backend: TicketBackendConfig,
|
pub backend: TicketBackendConfig,
|
||||||
pub ticket: TicketRecordConfig,
|
pub ticket: TicketRecordConfig,
|
||||||
|
pub orchestration: TicketOrchestrationConfig,
|
||||||
pub roles: TicketRoleProfiles,
|
pub roles: TicketRoleProfiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct TicketOrchestrationConfig {
|
||||||
|
pub branch: Option<GitBranchName>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TicketOrchestrationConfig {
|
||||||
|
pub fn branch_name(&self) -> Option<&str> {
|
||||||
|
self.branch.as_ref().map(GitBranchName::as_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||||
|
pub struct GitBranchName(String);
|
||||||
|
|
||||||
|
impl GitBranchName {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||||
|
let value = value.into();
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed != value {
|
||||||
|
return Err("git branch name must not have leading or trailing whitespace".to_string());
|
||||||
|
}
|
||||||
|
validate_git_branch_name_value(trimmed)?;
|
||||||
|
Ok(Self(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for GitBranchName {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Self::new(value).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for GitBranchName {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_git_branch_name_value(value: &str) -> Result<(), String> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err("git branch name must not be empty".to_string());
|
||||||
|
}
|
||||||
|
if value == "@" {
|
||||||
|
return Err("git branch name must not be `@`".to_string());
|
||||||
|
}
|
||||||
|
if value.starts_with('-') {
|
||||||
|
return Err("git branch name must not start with `-`".to_string());
|
||||||
|
}
|
||||||
|
if value.starts_with("refs/") {
|
||||||
|
return Err("git branch name must be a short branch name, not a full ref".to_string());
|
||||||
|
}
|
||||||
|
if value.starts_with('/') || value.ends_with('/') || value.contains("//") {
|
||||||
|
return Err("git branch name must not contain empty path components".to_string());
|
||||||
|
}
|
||||||
|
if value.contains("..") {
|
||||||
|
return Err("git branch name must not contain `..`".to_string());
|
||||||
|
}
|
||||||
|
if value.contains("@{") {
|
||||||
|
return Err("git branch name must not contain `@{`".to_string());
|
||||||
|
}
|
||||||
|
if value.ends_with('.') {
|
||||||
|
return Err("git branch name must not end with `.`".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
for component in value.split('/') {
|
||||||
|
if component.starts_with('.') {
|
||||||
|
return Err("git branch name components must not start with `.`".to_string());
|
||||||
|
}
|
||||||
|
if component.ends_with(".lock") {
|
||||||
|
return Err("git branch name components must not end with `.lock`".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ch in value.chars() {
|
||||||
|
if ch.is_control() || matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') {
|
||||||
|
return Err(format!(
|
||||||
|
"git branch name contains unsupported character `{}`",
|
||||||
|
ch.escape_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl TicketConfig {
|
impl TicketConfig {
|
||||||
pub fn default_for_workspace(workspace_root: impl AsRef<Path>) -> Self {
|
pub fn default_for_workspace(workspace_root: impl AsRef<Path>) -> Self {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
Self {
|
Self {
|
||||||
backend: TicketBackendConfig::default_for_workspace(workspace_root),
|
backend: TicketBackendConfig::default_for_workspace(workspace_root),
|
||||||
ticket: TicketRecordConfig::default(),
|
ticket: TicketRecordConfig::default(),
|
||||||
|
orchestration: TicketOrchestrationConfig::default(),
|
||||||
roles: TicketRoleProfiles::default(),
|
roles: TicketRoleProfiles::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -528,9 +626,26 @@ struct RawTicketConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
ticket: RawTicketRecordConfig,
|
ticket: RawTicketRecordConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
orchestration: RawTicketOrchestrationConfig,
|
||||||
|
#[serde(default)]
|
||||||
roles: BTreeMap<String, RawTicketRoleConfig>,
|
roles: BTreeMap<String, RawTicketRoleConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct RawTicketOrchestrationConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
branch: Option<GitBranchName>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTicketOrchestrationConfig {
|
||||||
|
fn resolve(self) -> TicketOrchestrationConfig {
|
||||||
|
TicketOrchestrationConfig {
|
||||||
|
branch: self.branch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct RawTicketRecordConfig {
|
struct RawTicketRecordConfig {
|
||||||
|
|
@ -576,6 +691,7 @@ impl RawTicketConfig {
|
||||||
}
|
}
|
||||||
})?,
|
})?,
|
||||||
ticket: self.ticket.resolve(),
|
ticket: self.ticket.resolve(),
|
||||||
|
orchestration: self.orchestration.resolve(),
|
||||||
roles,
|
roles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -680,6 +796,7 @@ mod tests {
|
||||||
temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)
|
temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)
|
||||||
);
|
);
|
||||||
assert_eq!(config.ticket_record_language(), None);
|
assert_eq!(config.ticket_record_language(), None);
|
||||||
|
assert_eq!(config.orchestration.branch_name(), None);
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
let role_config = config.role(role);
|
let role_config = config.role(role);
|
||||||
assert_eq!(role_config.profile.as_str(), "inherit");
|
assert_eq!(role_config.profile.as_str(), "inherit");
|
||||||
|
|
@ -701,6 +818,9 @@ root = "custom-tickets"
|
||||||
[ticket]
|
[ticket]
|
||||||
language = "Japanese"
|
language = "Japanese"
|
||||||
|
|
||||||
|
[orchestration]
|
||||||
|
branch = "orchestration/custom-panel"
|
||||||
|
|
||||||
[roles.intake]
|
[roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "$workspace/ticket/intake/launch"
|
||||||
|
|
@ -730,6 +850,10 @@ workflow = "multi-agent-workflow"
|
||||||
);
|
);
|
||||||
assert_eq!(config.backend.root, temp.path().join("custom-tickets"));
|
assert_eq!(config.backend.root, temp.path().join("custom-tickets"));
|
||||||
assert_eq!(config.ticket_record_language(), Some("Japanese"));
|
assert_eq!(config.ticket_record_language(), Some("Japanese"));
|
||||||
|
assert_eq!(
|
||||||
|
config.orchestration.branch_name(),
|
||||||
|
Some("orchestration/custom-panel")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.profile_for(TicketRole::Intake).as_str(),
|
config.profile_for(TicketRole::Intake).as_str(),
|
||||||
"project:intake"
|
"project:intake"
|
||||||
|
|
@ -756,6 +880,9 @@ workflow = "multi-agent-workflow"
|
||||||
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
|
assert!(scaffold.contains("provider = \"builtin:yoi_local\""));
|
||||||
assert!(scaffold.contains("root = \".yoi/tickets\""));
|
assert!(scaffold.contains("root = \".yoi/tickets\""));
|
||||||
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\""));
|
assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\""));
|
||||||
|
assert!(scaffold.contains(
|
||||||
|
"# [orchestration]\n# branch = \"orchestration/<workspace-orchestrator-pod-name>\""
|
||||||
|
));
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
assert!(scaffold.contains(&format!("[roles.{role}]")));
|
assert!(scaffold.contains(&format!("[roles.{role}]")));
|
||||||
assert!(scaffold.contains(&format!(
|
assert!(scaffold.contains(&format!(
|
||||||
|
|
@ -773,6 +900,7 @@ workflow = "multi-agent-workflow"
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
|
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
|
||||||
|
assert_eq!(config.orchestration.branch_name(), None);
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
let role_config = config.role_launch_config(role).unwrap();
|
let role_config = config.role_launch_config(role).unwrap();
|
||||||
assert_eq!(role_config.profile.as_str(), role.default_profile());
|
assert_eq!(role_config.profile.as_str(), role.default_profile());
|
||||||
|
|
@ -851,6 +979,32 @@ profile = "builtin:default"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestration_branch_config_is_validated_as_git_branch_name() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
write_config(
|
||||||
|
temp.path(),
|
||||||
|
r#"
|
||||||
|
[orchestration]
|
||||||
|
branch = "orchestration/panel:bad"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("git branch name"));
|
||||||
|
assert!(error.to_string().contains("unsupported character"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestration_branch_rejects_full_refs_and_dash_prefixes() {
|
||||||
|
assert!(GitBranchName::new("refs/heads/orchestration/panel").is_err());
|
||||||
|
assert!(GitBranchName::new("-orchestration-panel").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
GitBranchName::new("orchestration/panel").unwrap().as_str(),
|
||||||
|
"orchestration/panel"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn role_table_without_profile_is_not_role_launch_ready() {
|
fn role_table_without_profile_is_not_role_launch_ready() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,6 +15,8 @@ use crossterm::event::{
|
||||||
};
|
};
|
||||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use crossterm::{Command, execute};
|
use crossterm::{Command, execute};
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||||
use protocol::{Method, PodStatus};
|
use protocol::{Method, PodStatus};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
|
@ -75,6 +77,15 @@ pub(crate) async fn run_pod_name(
|
||||||
socket_override: Option<PathBuf>,
|
socket_override: Option<PathBuf>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||||
|
let mut terminal = enter_fullscreen()?;
|
||||||
|
terminal.clear()?;
|
||||||
|
let result = run_e2e_rewind_fixture(&mut terminal, pod_name).await;
|
||||||
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
|
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
|
||||||
|
|
@ -248,6 +259,16 @@ pub(crate) async fn run_spawn(
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||||
|
let mut terminal = enter_fullscreen()?;
|
||||||
|
terminal.clear()?;
|
||||||
|
let fixture_pod_name = pod_name.unwrap_or_else(|| "e2e-rewind".to_string());
|
||||||
|
let result = run_e2e_rewind_fixture(&mut terminal, fixture_pod_name).await;
|
||||||
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? {
|
let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? {
|
||||||
SpawnOutcome::Ready(r) => r,
|
SpawnOutcome::Ready(r) => r,
|
||||||
SpawnOutcome::Cancelled => return Ok(()),
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
|
|
@ -388,6 +409,181 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
async fn run_e2e_rewind_fixture(
|
||||||
|
terminal: &mut FullscreenTerminal,
|
||||||
|
pod_name: String,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
|
let mut app = App::new_with_persistent_input_history(pod_name.clone(), &workspace_root);
|
||||||
|
app.connected = true;
|
||||||
|
app.handle_pod_event(Event::Snapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
status: PodStatus::Idle,
|
||||||
|
greeting: Greeting {
|
||||||
|
pod_name: pod_name.clone(),
|
||||||
|
cwd: workspace_root.display().to_string(),
|
||||||
|
provider: "e2e-fixture".to_string(),
|
||||||
|
model: "canned".to_string(),
|
||||||
|
scope_summary: "isolated e2e rewind fixture".to_string(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
context_window: 0,
|
||||||
|
context_tokens: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let (_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||||
|
let target_id = RewindTargetId {
|
||||||
|
segment_id: uuid::Uuid::from_u128(1),
|
||||||
|
user_input_entry_index: 1,
|
||||||
|
};
|
||||||
|
let mut rewind_submit_count = 0usize;
|
||||||
|
let mut pending_apply: Option<std::time::Instant> = None;
|
||||||
|
let apply_delay = Duration::from_millis(400);
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
crate::e2e_observer::emit(
|
||||||
|
"single_pod",
|
||||||
|
"rewind_fixture_ready",
|
||||||
|
serde_json::json!({ "pod": pod_name.clone() }),
|
||||||
|
);
|
||||||
|
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let wait = pending_apply.map(|submitted_at| {
|
||||||
|
apply_delay
|
||||||
|
.checked_sub(submitted_at.elapsed())
|
||||||
|
.unwrap_or(Duration::ZERO)
|
||||||
|
});
|
||||||
|
let input = match wait {
|
||||||
|
Some(Duration::ZERO) => E2eRewindInput::Tick,
|
||||||
|
Some(timeout) => match tokio::time::timeout(timeout, term_rx.recv()).await {
|
||||||
|
Ok(Some(Ok(event))) => E2eRewindInput::Terminal(event),
|
||||||
|
Ok(Some(Err(err))) => return Err(Box::new(err)),
|
||||||
|
Ok(None) => E2eRewindInput::TerminalClosed,
|
||||||
|
Err(_) => E2eRewindInput::Tick,
|
||||||
|
},
|
||||||
|
None => match term_rx.recv().await {
|
||||||
|
Some(Ok(event)) => E2eRewindInput::Terminal(event),
|
||||||
|
Some(Err(err)) => return Err(Box::new(err)),
|
||||||
|
None => E2eRewindInput::TerminalClosed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut needs_draw = false;
|
||||||
|
match input {
|
||||||
|
E2eRewindInput::Terminal(TermEvent::Key(key)) => {
|
||||||
|
let duplicate_enter_pending = matches!(key.code, KeyCode::Enter)
|
||||||
|
&& app
|
||||||
|
.rewind_picker
|
||||||
|
.as_ref()
|
||||||
|
.map(|picker| picker.applying)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if let Some(method) = handle_key(&mut app, key) {
|
||||||
|
match method {
|
||||||
|
Method::ListRewindTargets => {
|
||||||
|
app.handle_pod_event(Event::RewindTargets {
|
||||||
|
head_entries: 3,
|
||||||
|
targets: vec![RewindTarget {
|
||||||
|
id: target_id.clone(),
|
||||||
|
expected_head_entries: 3,
|
||||||
|
truncate_entries: 1,
|
||||||
|
turn_index: 1,
|
||||||
|
timestamp_ms: Some(1),
|
||||||
|
preview: "revise the plan".to_string(),
|
||||||
|
eligible: true,
|
||||||
|
disabled_reason: None,
|
||||||
|
warning: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
crate::e2e_observer::emit(
|
||||||
|
"single_pod",
|
||||||
|
"rewind_picker_opened",
|
||||||
|
serde_json::json!({
|
||||||
|
"targets": 1,
|
||||||
|
"selected_preview": "revise the plan",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Method::RewindTo {
|
||||||
|
target,
|
||||||
|
expected_head_entries,
|
||||||
|
} => {
|
||||||
|
rewind_submit_count += 1;
|
||||||
|
pending_apply = Some(std::time::Instant::now());
|
||||||
|
crate::e2e_observer::emit(
|
||||||
|
"single_pod",
|
||||||
|
"rewind_submit_sent",
|
||||||
|
serde_json::json!({
|
||||||
|
"segment_id": target.segment_id.to_string(),
|
||||||
|
"user_input_entry_index": target.user_input_entry_index,
|
||||||
|
"expected_head_entries": expected_head_entries,
|
||||||
|
"submit_count": rewind_submit_count,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if duplicate_enter_pending {
|
||||||
|
crate::e2e_observer::emit(
|
||||||
|
"single_pod",
|
||||||
|
"rewind_duplicate_enter_suppressed",
|
||||||
|
serde_json::json!({ "submit_count": rewind_submit_count }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
needs_draw = true;
|
||||||
|
}
|
||||||
|
E2eRewindInput::Terminal(TermEvent::Mouse(_))
|
||||||
|
| E2eRewindInput::Terminal(TermEvent::Resize(_, _))
|
||||||
|
| E2eRewindInput::Tick => {
|
||||||
|
needs_draw = true;
|
||||||
|
}
|
||||||
|
E2eRewindInput::TerminalClosed => break,
|
||||||
|
E2eRewindInput::Terminal(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(submitted_at) = pending_apply {
|
||||||
|
if submitted_at.elapsed() >= apply_delay {
|
||||||
|
app.handle_pod_event(Event::RewindApplied {
|
||||||
|
entries: Vec::new(),
|
||||||
|
input: vec![Segment::text("revise the plan")],
|
||||||
|
summary: RewindSummary {
|
||||||
|
truncated_to_entries: 1,
|
||||||
|
discarded_entries: 2,
|
||||||
|
tool_side_effect_warning: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pending_apply = None;
|
||||||
|
let composer_text = Segment::flatten_to_text(&app.input.submit_segments());
|
||||||
|
crate::e2e_observer::emit(
|
||||||
|
"single_pod",
|
||||||
|
"rewind_applied",
|
||||||
|
serde_json::json!({
|
||||||
|
"composer_text": composer_text,
|
||||||
|
"submit_count": rewind_submit_count,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
needs_draw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if needs_draw {
|
||||||
|
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
enum E2eRewindInput {
|
||||||
|
Terminal(TermEvent),
|
||||||
|
TerminalClosed,
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
|
||||||
enum LoopInput<P> {
|
enum LoopInput<P> {
|
||||||
Terminal(TerminalEventResult),
|
Terminal(TerminalEventResult),
|
||||||
Pod(Option<P>),
|
Pod(Option<P>),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ version = "0.0.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
autotests = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|
@ -19,3 +20,8 @@ tempfile.workspace = true
|
||||||
name = "panel"
|
name = "panel"
|
||||||
path = "tests/panel.rs"
|
path = "tests/panel.rs"
|
||||||
required-features = ["e2e"]
|
required-features = ["e2e"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "rewind"
|
||||||
|
path = "tests/rewind.rs"
|
||||||
|
required-features = ["e2e"]
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ fn fixture_setup_env_policy() -> EnvPolicy {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy {
|
fn tui_env_policy(include_hold_background_task: bool, include_rewind_fixture: bool) -> EnvPolicy {
|
||||||
let mut allowlist = vec![
|
let mut allowlist = vec![
|
||||||
"HOME",
|
"HOME",
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
|
|
@ -118,12 +118,19 @@ fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy {
|
||||||
if include_hold_background_task {
|
if include_hold_background_task {
|
||||||
allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK");
|
allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK");
|
||||||
}
|
}
|
||||||
|
if include_rewind_fixture {
|
||||||
|
allowlist.push("YOI_TUI_TEST_REWIND_FIXTURE");
|
||||||
|
}
|
||||||
env_policy(
|
env_policy(
|
||||||
&allowlist,
|
&allowlist,
|
||||||
"tested yoi panel subprocess uses env_clear and receives only fixture HOME, XDG data/state/config/runtime dirs, terminal/test-observer variables, and the explicit runtime binary override",
|
"tested yoi TUI subprocess uses env_clear and receives only fixture HOME, XDG data/state/config/runtime dirs, terminal/test-observer variables, explicit e2e fixture toggles, and the explicit runtime binary override",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy {
|
||||||
|
tui_env_policy(include_hold_background_task, false)
|
||||||
|
}
|
||||||
|
|
||||||
fn tested_yoi_env_policy_overview() -> TestedYoiEnvPolicy {
|
fn tested_yoi_env_policy_overview() -> TestedYoiEnvPolicy {
|
||||||
TestedYoiEnvPolicy {
|
TestedYoiEnvPolicy {
|
||||||
fixture_setup: fixture_setup_env_policy(),
|
fixture_setup: fixture_setup_env_policy(),
|
||||||
|
|
@ -150,6 +157,9 @@ pub enum HarnessError {
|
||||||
MouseCaptureNotEnabled {
|
MouseCaptureNotEnabled {
|
||||||
artifacts: PanelArtifacts,
|
artifacts: PanelArtifacts,
|
||||||
},
|
},
|
||||||
|
FullDragMouseCaptureEnabled {
|
||||||
|
artifacts: PanelArtifacts,
|
||||||
|
},
|
||||||
Protocol(String),
|
Protocol(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +194,11 @@ impl std::fmt::Display for HarnessError {
|
||||||
"terminal mouse capture was not observed before mouse input; artifacts at {}",
|
"terminal mouse capture was not observed before mouse input; artifacts at {}",
|
||||||
artifacts.dir.display()
|
artifacts.dir.display()
|
||||||
),
|
),
|
||||||
|
Self::FullDragMouseCaptureEnabled { artifacts } => write!(
|
||||||
|
f,
|
||||||
|
"forbidden full drag-motion mouse capture (?1002h/?1003h) was observed; artifacts at {}",
|
||||||
|
artifacts.dir.display()
|
||||||
|
),
|
||||||
Self::Protocol(message) => write!(f, "protocol error: {message}"),
|
Self::Protocol(message) => write!(f, "protocol error: {message}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +230,8 @@ pub struct PanelHarnessConfig {
|
||||||
pub fixture_root: PathBuf,
|
pub fixture_root: PathBuf,
|
||||||
pub terminal_size: (u16, u16),
|
pub terminal_size: (u16, u16),
|
||||||
pub hold_background_task: Option<String>,
|
pub hold_background_task: Option<String>,
|
||||||
|
pub rewind_fixture: bool,
|
||||||
|
pub command_args: Vec<String>,
|
||||||
pub artifacts_dir: PathBuf,
|
pub artifacts_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +277,7 @@ pub struct RowsRendered {
|
||||||
pub enum KeyPress {
|
pub enum KeyPress {
|
||||||
CtrlC,
|
CtrlC,
|
||||||
CtrlD,
|
CtrlD,
|
||||||
|
CtrlR,
|
||||||
Enter,
|
Enter,
|
||||||
Esc,
|
Esc,
|
||||||
Text(String),
|
Text(String),
|
||||||
|
|
@ -299,11 +317,13 @@ impl PanelHarness {
|
||||||
fs::write(&artifacts.events_jsonl, "")?;
|
fs::write(&artifacts.events_jsonl, "")?;
|
||||||
fs::write(&artifacts.input_log, "")?;
|
fs::write(&artifacts.input_log, "")?;
|
||||||
fs::write(&artifacts.output_log, "")?;
|
fs::write(&artifacts.output_log, "")?;
|
||||||
let env_policy = panel_env_policy(config.hold_background_task.is_some());
|
let env_policy =
|
||||||
|
tui_env_policy(config.hold_background_task.is_some(), config.rewind_fixture);
|
||||||
fs::write(
|
fs::write(
|
||||||
&artifacts.run_json,
|
&artifacts.run_json,
|
||||||
serde_json::to_vec_pretty(&serde_json::json!({
|
serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
"binary": config.binary,
|
"binary": config.binary,
|
||||||
|
"args": &config.command_args,
|
||||||
"workspace": config.workspace,
|
"workspace": config.workspace,
|
||||||
"home": config.home,
|
"home": config.home,
|
||||||
"xdg_data_home": config.xdg_data_home,
|
"xdg_data_home": config.xdg_data_home,
|
||||||
|
|
@ -321,6 +341,7 @@ impl PanelHarness {
|
||||||
"rows": config.terminal_size.1,
|
"rows": config.terminal_size.1,
|
||||||
},
|
},
|
||||||
"hold_background_task": config.hold_background_task,
|
"hold_background_task": config.hold_background_task,
|
||||||
|
"rewind_fixture": config.rewind_fixture,
|
||||||
"tested_yoi_env_policy": &env_policy,
|
"tested_yoi_env_policy": &env_policy,
|
||||||
}))?,
|
}))?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -331,9 +352,7 @@ impl PanelHarness {
|
||||||
|
|
||||||
let mut command = Command::new(&config.binary);
|
let mut command = Command::new(&config.binary);
|
||||||
command
|
command
|
||||||
.arg("panel")
|
.args(&config.command_args)
|
||||||
.arg("--workspace")
|
|
||||||
.arg(&config.workspace)
|
|
||||||
.env_clear()
|
.env_clear()
|
||||||
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
|
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
|
||||||
.env("YOI_POD_RUNTIME_COMMAND", &config.binary)
|
.env("YOI_POD_RUNTIME_COMMAND", &config.binary)
|
||||||
|
|
@ -349,6 +368,9 @@ impl PanelHarness {
|
||||||
if let Some(task) = &config.hold_background_task {
|
if let Some(task) = &config.hold_background_task {
|
||||||
command.env("YOI_TUI_TEST_HOLD_BACKGROUND_TASK", task);
|
command.env("YOI_TUI_TEST_HOLD_BACKGROUND_TASK", task);
|
||||||
}
|
}
|
||||||
|
if config.rewind_fixture {
|
||||||
|
command.env("YOI_TUI_TEST_REWIND_FIXTURE", "1");
|
||||||
|
}
|
||||||
let child = command.spawn()?;
|
let child = command.spawn()?;
|
||||||
|
|
||||||
let output = Arc::new(Mutex::new(Vec::new()));
|
let output = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
@ -457,6 +479,58 @@ impl PanelHarness {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn assert_no_full_drag_mouse_capture(&mut self) -> Result<()> {
|
||||||
|
if self.full_drag_mouse_capture_observed() {
|
||||||
|
self.flush_output_artifact()?;
|
||||||
|
return Err(HarnessError::FullDragMouseCaptureEnabled {
|
||||||
|
artifacts: self.artifacts.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expect_event(
|
||||||
|
&mut self,
|
||||||
|
event_name: &'static str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<HarnessEvent> {
|
||||||
|
self.wait_for(event_name, timeout, |event| event.event == event_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count_events(&mut self, event_name: &str) -> Result<usize> {
|
||||||
|
Ok(self
|
||||||
|
.events()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| event.event == event_name)
|
||||||
|
.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_no_additional_events(
|
||||||
|
&mut self,
|
||||||
|
event_name: &str,
|
||||||
|
baseline: usize,
|
||||||
|
duration: Duration,
|
||||||
|
) -> Result<()> {
|
||||||
|
let start = Instant::now();
|
||||||
|
while start.elapsed() < duration {
|
||||||
|
if let Some(status) = self.child.try_wait()? {
|
||||||
|
self.flush_output_artifact()?;
|
||||||
|
return Err(HarnessError::Protocol(format!(
|
||||||
|
"process exited with {status} while waiting for no additional {event_name} events"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let count = self.count_events(event_name)?;
|
||||||
|
if count > baseline {
|
||||||
|
self.flush_output_artifact()?;
|
||||||
|
return Err(HarnessError::Protocol(format!(
|
||||||
|
"observed {count} {event_name} events; expected no more than {baseline}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn expect_background_task_pending(&mut self, task: &str) -> Result<()> {
|
pub fn expect_background_task_pending(&mut self, task: &str) -> Result<()> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -495,10 +569,40 @@ impl PanelHarness {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn wheel_down(&mut self, row: &RenderedPanelRow) -> Result<()> {
|
||||||
|
self.wheel(row, 65, "down")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wheel_up(&mut self, row: &RenderedPanelRow) -> Result<()> {
|
||||||
|
self.wheel(row, 64, "up")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wheel(&mut self, row: &RenderedPanelRow, sgr_button: u8, label: &str) -> Result<()> {
|
||||||
|
if !self.mouse_capture_enabled() {
|
||||||
|
self.flush_output_artifact()?;
|
||||||
|
return Err(HarnessError::MouseCaptureNotEnabled {
|
||||||
|
artifacts: self.artifacts.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let x = row.rect.x.saturating_add(1);
|
||||||
|
let y = row.rect.y;
|
||||||
|
self.write_input(
|
||||||
|
&format!("mouse wheel {label} {} at {},{}", row.title, x, y),
|
||||||
|
format!(
|
||||||
|
"\u{1b}[<{};{};{}M",
|
||||||
|
sgr_button,
|
||||||
|
x.saturating_add(1),
|
||||||
|
y.saturating_add(1)
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn press(&mut self, key: KeyPress) -> Result<()> {
|
pub fn press(&mut self, key: KeyPress) -> Result<()> {
|
||||||
match key {
|
match key {
|
||||||
KeyPress::CtrlC => self.write_input("Ctrl+C", b"\x03"),
|
KeyPress::CtrlC => self.write_input("Ctrl+C", b"\x03"),
|
||||||
KeyPress::CtrlD => self.write_input("Ctrl+D", b"\x04"),
|
KeyPress::CtrlD => self.write_input("Ctrl+D", b"\x04"),
|
||||||
|
KeyPress::CtrlR => self.write_input("Ctrl+R", b"\x12"),
|
||||||
KeyPress::Enter => self.write_input("Enter", b"\r"),
|
KeyPress::Enter => self.write_input("Enter", b"\r"),
|
||||||
KeyPress::Esc => self.write_input("Esc", b"\x1b"),
|
KeyPress::Esc => self.write_input("Esc", b"\x1b"),
|
||||||
KeyPress::Text(text) => self.write_input(&format!("text {text:?}"), text.as_bytes()),
|
KeyPress::Text(text) => self.write_input(&format!("text {text:?}"), text.as_bytes()),
|
||||||
|
|
@ -587,6 +691,13 @@ impl PanelHarness {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn full_drag_mouse_capture_observed(&self) -> bool {
|
||||||
|
self.output
|
||||||
|
.lock()
|
||||||
|
.map(|output| output_has_full_drag_mouse_capture(&output))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn flush_output_artifact(&self) -> Result<()> {
|
fn flush_output_artifact(&self) -> Result<()> {
|
||||||
if let Ok(output) = self.output.lock() {
|
if let Ok(output) = self.output.lock() {
|
||||||
fs::write(&self.artifacts.output_log, &*output)?;
|
fs::write(&self.artifacts.output_log, &*output)?;
|
||||||
|
|
@ -744,6 +855,12 @@ impl FixtureWorkspace {
|
||||||
fixture_root: self.root.clone(),
|
fixture_root: self.root.clone(),
|
||||||
terminal_size: (100, 32),
|
terminal_size: (100, 32),
|
||||||
hold_background_task: None,
|
hold_background_task: None,
|
||||||
|
rewind_fixture: false,
|
||||||
|
command_args: vec![
|
||||||
|
"panel".to_string(),
|
||||||
|
"--workspace".to_string(),
|
||||||
|
self.workspace.display().to_string(),
|
||||||
|
],
|
||||||
artifacts_dir: self.artifacts_dir.clone(),
|
artifacts_dir: self.artifacts_dir.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -758,6 +875,19 @@ impl FixtureWorkspace {
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rewind_fixture_config(&self, binary: PathBuf) -> PanelHarnessConfig {
|
||||||
|
let mut config = self.panel_config(binary);
|
||||||
|
config.rewind_fixture = true;
|
||||||
|
config.command_args = vec![
|
||||||
|
"--workspace".to_string(),
|
||||||
|
self.workspace.display().to_string(),
|
||||||
|
"--pod".to_string(),
|
||||||
|
"e2e-rewind".to_string(),
|
||||||
|
];
|
||||||
|
config.artifacts_dir = self.artifacts_dir.join("rewind");
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cleanup(mut self) -> Result<FixtureCleanupReport> {
|
pub fn cleanup(mut self) -> Result<FixtureCleanupReport> {
|
||||||
self.cleanup_inner(true)
|
self.cleanup_inner(true)
|
||||||
}
|
}
|
||||||
|
|
@ -1169,6 +1299,15 @@ fn output_has_enabled_mouse_capture(output: &[u8]) -> bool {
|
||||||
&& mouse_mode_enabled(output, b"\x1b[?1006h", b"\x1b[?1006l")
|
&& mouse_mode_enabled(output, b"\x1b[?1006h", b"\x1b[?1006l")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn output_has_full_drag_mouse_capture(output: &[u8]) -> bool {
|
||||||
|
output
|
||||||
|
.windows(b"\x1b[?1002h".len())
|
||||||
|
.any(|window| window == b"\x1b[?1002h")
|
||||||
|
|| output
|
||||||
|
.windows(b"\x1b[?1003h".len())
|
||||||
|
.any(|window| window == b"\x1b[?1003h")
|
||||||
|
}
|
||||||
|
|
||||||
fn mouse_mode_enabled(output: &[u8], enable: &[u8], disable: &[u8]) -> bool {
|
fn mouse_mode_enabled(output: &[u8], enable: &[u8], disable: &[u8]) -> bool {
|
||||||
let last_enable = last_subsequence_index(output, enable);
|
let last_enable = last_subsequence_index(output, enable);
|
||||||
let last_disable = last_subsequence_index(output, disable);
|
let last_disable = last_subsequence_index(output, disable);
|
||||||
|
|
@ -1253,6 +1392,7 @@ mod tests {
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
"XDG_STATE_HOME",
|
"XDG_STATE_HOME",
|
||||||
"XDG_CONFIG_HOME",
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
"YOI_POD_RUNTIME_COMMAND",
|
"YOI_POD_RUNTIME_COMMAND",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -1266,6 +1406,7 @@ mod tests {
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
"XDG_STATE_HOME",
|
"XDG_STATE_HOME",
|
||||||
"XDG_CONFIG_HOME",
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
"TERM",
|
"TERM",
|
||||||
"YOI_TUI_TEST_EVENTS",
|
"YOI_TUI_TEST_EVENTS",
|
||||||
"YOI_POD_RUNTIME_COMMAND",
|
"YOI_POD_RUNTIME_COMMAND",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result
|
||||||
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
||||||
|
|
||||||
panel.expect_mouse_capture_enabled()?;
|
panel.expect_mouse_capture_enabled()?;
|
||||||
|
panel.assert_no_full_drag_mouse_capture()?;
|
||||||
let rows = panel.wait_for_rows(2)?;
|
let rows = panel.wait_for_rows(2)?;
|
||||||
assert_no_runtime_or_host_pod_leak(
|
assert_no_runtime_or_host_pod_leak(
|
||||||
&fixture,
|
&fixture,
|
||||||
|
|
@ -38,6 +39,69 @@ fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result
|
||||||
"mouse selection must not dispatch panel actions; artifacts at {}",
|
"mouse selection must not dispatch panel actions; artifacts at {}",
|
||||||
panel.artifacts().dir.display()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
panel.assert_no_full_drag_mouse_capture()?;
|
||||||
|
|
||||||
|
panel.press(KeyPress::CtrlC)?;
|
||||||
|
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||||
|
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
|
||||||
|
drop(panel);
|
||||||
|
assert_fixture_cleanup(fixture.cleanup()?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_mouse_wheel_moves_selection_without_full_drag_capture() -> yoi_e2e::Result<()> {
|
||||||
|
let binary = yoi_binary()?;
|
||||||
|
let fixture = FixtureWorkspace::new(&binary)?;
|
||||||
|
assert_fixture_paths_are_isolated(&fixture);
|
||||||
|
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
||||||
|
|
||||||
|
panel.expect_mouse_capture_enabled()?;
|
||||||
|
panel.assert_no_full_drag_mouse_capture()?;
|
||||||
|
let rows = panel.wait_for_rows(2)?;
|
||||||
|
assert_no_runtime_or_host_pod_leak(
|
||||||
|
&fixture,
|
||||||
|
&rows.rows,
|
||||||
|
panel.artifacts().dir.display().to_string().as_str(),
|
||||||
|
);
|
||||||
|
let selected = rows
|
||||||
|
.selected
|
||||||
|
.as_ref()
|
||||||
|
.expect("fixture should render an initially selected row")
|
||||||
|
.clone();
|
||||||
|
let selected_index = rows
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.position(|row| row.key == selected)
|
||||||
|
.expect("selected row should be rendered");
|
||||||
|
let target_index = (selected_index + 1).min(rows.rows.len() - 1);
|
||||||
|
assert_ne!(
|
||||||
|
selected_index, target_index,
|
||||||
|
"fixture should render a wheel-selectable next row"
|
||||||
|
);
|
||||||
|
let source = rows.rows[selected_index].clone();
|
||||||
|
let target = rows.rows[target_index].clone();
|
||||||
|
|
||||||
|
let before_events = panel.events()?.len();
|
||||||
|
panel.wheel_down(&source)?;
|
||||||
|
panel.expect_selection(&target.key)?;
|
||||||
|
panel.assert_no_full_drag_mouse_capture()?;
|
||||||
|
|
||||||
|
let events = panel.events()?;
|
||||||
|
assert!(
|
||||||
|
events[before_events..]
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.event == "mouse_wheel"),
|
||||||
|
"wheel movement should be visible in e2e events; artifacts at {}",
|
||||||
|
panel.artifacts().dir.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
events[before_events..]
|
||||||
|
.iter()
|
||||||
|
.all(|event| event.event != "action_requested"),
|
||||||
|
"wheel selection must not dispatch panel actions; artifacts at {}",
|
||||||
|
panel.artifacts().dir.display()
|
||||||
|
);
|
||||||
|
|
||||||
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())?;
|
||||||
|
|
|
||||||
61
tests/e2e/tests/rewind.rs
Normal file
61
tests/e2e/tests/rewind.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use yoi_e2e::{FixtureWorkspace, KeyPress, PanelHarness, yoi_binary};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter()
|
||||||
|
-> yoi_e2e::Result<()> {
|
||||||
|
let binary = yoi_binary()?;
|
||||||
|
let fixture = FixtureWorkspace::new(&binary)?;
|
||||||
|
let mut tui = PanelHarness::spawn(fixture.rewind_fixture_config(binary))?;
|
||||||
|
|
||||||
|
tui.expect_mouse_capture_enabled()?;
|
||||||
|
tui.assert_no_full_drag_mouse_capture()?;
|
||||||
|
tui.expect_event("rewind_fixture_ready", Duration::from_secs(5))?;
|
||||||
|
|
||||||
|
tui.press(KeyPress::CtrlR)?;
|
||||||
|
tui.expect_event("rewind_picker_opened", Duration::from_secs(5))?;
|
||||||
|
|
||||||
|
tui.press(KeyPress::Enter)?;
|
||||||
|
tui.expect_event("rewind_submit_sent", Duration::from_secs(5))?;
|
||||||
|
let submit_count = tui.count_events("rewind_submit_sent")?;
|
||||||
|
|
||||||
|
tui.press(KeyPress::Enter)?;
|
||||||
|
tui.expect_event("rewind_duplicate_enter_suppressed", Duration::from_secs(5))?;
|
||||||
|
tui.wait_for_no_additional_events(
|
||||||
|
"rewind_submit_sent",
|
||||||
|
submit_count,
|
||||||
|
Duration::from_millis(250),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let applied = tui.expect_event("rewind_applied", Duration::from_secs(5))?;
|
||||||
|
assert_eq!(
|
||||||
|
applied
|
||||||
|
.data
|
||||||
|
.get("composer_text")
|
||||||
|
.and_then(serde_json::Value::as_str),
|
||||||
|
Some("revise the plan"),
|
||||||
|
"rewind should update the visible composer state without Esc/restart; artifacts at {}",
|
||||||
|
tui.artifacts().dir.display()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tui.count_events("rewind_submit_sent")?,
|
||||||
|
submit_count,
|
||||||
|
"pending Enter must not duplicate destructive rewind submissions; artifacts at {}",
|
||||||
|
tui.artifacts().dir.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
tui.press(KeyPress::CtrlD)?;
|
||||||
|
let status = tui.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||||
|
assert!(
|
||||||
|
status.success(),
|
||||||
|
"single-pod rewind fixture should exit cleanly"
|
||||||
|
);
|
||||||
|
drop(tui);
|
||||||
|
let cleanup = fixture.cleanup()?;
|
||||||
|
assert!(
|
||||||
|
cleanup.cleanup_success,
|
||||||
|
"fixture cleanup failed: {cleanup:?}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user