diff --git a/.yoi/tickets/00001KV09X0XC/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV09X0XC/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..e81443f2 --- /dev/null +++ b/.yoi/tickets/00001KV09X0XC/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KV09X0XC/item.md b/.yoi/tickets/00001KV09X0XC/item.md index ef095151..84763a35 100644 --- a/.yoi/tickets/00001KV09X0XC/item.md +++ b/.yoi/tickets/00001KV09X0XC/item.md @@ -1,8 +1,8 @@ --- title: 'Panel から ready Ticket を指示付きで planning に戻して Intake を再開できるようにする' -state: 'queued' +state: 'done' created_at: '2026-06-13T10:54:34Z' -updated_at: '2026-06-13T16:33:26Z' +updated_at: '2026-06-14T05:09:07Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel-action', 'ticket-lifecycle', 'role-session', 'authority-boundary'] diff --git a/.yoi/tickets/00001KV09X0XC/thread.md b/.yoi/tickets/00001KV09X0XC/thread.md index 260c3547..664cf42a 100644 --- a/.yoi/tickets/00001KV09X0XC/thread.md +++ b/.yoi/tickets/00001KV09X0XC/thread.md @@ -29,4 +29,157 @@ Intake refinement completed. ユーザーが draft を承認し、意図・受 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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`。 + +--- + + + +## State changed + +Ticket evidence、relation records、orchestration plan、workspace state を確認した。Blocking relation はなく、implementation-ready。実装 side effect の前に inprogress acceptance を記録する。 + +--- + + + +## 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 を起動する。 + +--- + + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + --- diff --git a/.yoi/tickets/00001KV0SP0TY/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV0SP0TY/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..9dfcb3fb --- /dev/null +++ b/.yoi/tickets/00001KV0SP0TY/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KV0SP0TY/item.md b/.yoi/tickets/00001KV0SP0TY/item.md index cd509f85..707cb26a 100644 --- a/.yoi/tickets/00001KV0SP0TY/item.md +++ b/.yoi/tickets/00001KV0SP0TY/item.md @@ -1,8 +1,8 @@ --- title: 'Remove feature-layer HostAuthority model' -state: 'queued' +state: 'done' created_at: '2026-06-13T15:30:22Z' -updated_at: '2026-06-13T16:33:15Z' +updated_at: '2026-06-13T19:02:01Z' assignee: null readiness: 'implementation_ready' risk_flags: ['feature-api', 'tool-registry', 'ticket-tools'] diff --git a/.yoi/tickets/00001KV0SP0TY/thread.md b/.yoi/tickets/00001KV0SP0TY/thread.md index 6ea5ccb6..dd7c2bb5 100644 --- a/.yoi/tickets/00001KV0SP0TY/thread.md +++ b/.yoi/tickets/00001KV0SP0TY/thread.md @@ -42,4 +42,167 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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`。 + +--- + + + +## State changed + +Ticket evidence、relation records、orchestration plan、workspace state を確認した。This Ticket 自身を blocking する relation はなく、incoming dependent Ticket の前提として先に進めるべき。実装 side effect の前に inprogress acceptance を記録する。 + +--- + + + +## 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 を起動する。 + +--- + + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + --- diff --git a/.yoi/tickets/00001KV0X254D/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV0X254D/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..16c69b2b --- /dev/null +++ b/.yoi/tickets/00001KV0X254D/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KV0X254D/item.md b/.yoi/tickets/00001KV0X254D/item.md index 3473e6a5..76e0c226 100644 --- a/.yoi/tickets/00001KV0X254D/item.md +++ b/.yoi/tickets/00001KV0X254D/item.md @@ -1,8 +1,8 @@ --- title: 'Panel Orchestrator の orchestration branch 名を ticket.config.toml で設定可能にする' -state: 'queued' +state: 'done' created_at: '2026-06-13T16:29:25Z' -updated_at: '2026-06-13T16:33:27Z' +updated_at: '2026-06-14T05:05:57Z' assignee: null readiness: 'implementation_ready' risk_flags: ['config-schema', 'git-worktree', 'panel-orchestration'] diff --git a/.yoi/tickets/00001KV0X254D/thread.md b/.yoi/tickets/00001KV0X254D/thread.md index cb240f13..d0e5b1b8 100644 --- a/.yoi/tickets/00001KV0X254D/thread.md +++ b/.yoi/tickets/00001KV0X254D/thread.md @@ -29,4 +29,164 @@ Intake 済み。要件・受け入れ条件・binding invariants・validation Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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/` は維持する。 +- 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`。 + +--- + + + +## State changed + +Ticket evidence、relation records、orchestration plan、workspace state を確認した。Blocking relation はなく、implementation-ready。実装 side effect の前に inprogress acceptance を記録する。 + +--- + + + +## 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 を起動する。 + +--- + + + +## 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/`. + - 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. + +--- + + + +## 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/`, 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. + +--- + + + +## 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. + +--- + + + +## 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. + --- diff --git a/.yoi/tickets/00001KV10SN02/item.md b/.yoi/tickets/00001KV10SN02/item.md index 3710e158..64e743e0 100644 --- a/.yoi/tickets/00001KV10SN02/item.md +++ b/.yoi/tickets/00001KV10SN02/item.md @@ -1,8 +1,8 @@ --- title: 'E2E: close remaining critical-path gaps after panel harness' -state: 'inprogress' +state: 'done' created_at: '2026-06-13T17:34:41Z' -updated_at: '2026-06-13T17:54:19Z' +updated_at: '2026-06-14T05:39:03Z' assignee: null readiness: 'implementation_ready' risk_flags: ['e2e', 'tui', 'pty', 'quit-latency', 'mouse-input', 'rewind'] diff --git a/.yoi/tickets/00001KV10SN02/thread.md b/.yoi/tickets/00001KV10SN02/thread.md index 3edff71d..5248dfe3 100644 --- a/.yoi/tickets/00001KV10SN02/thread.md +++ b/.yoi/tickets/00001KV10SN02/thread.md @@ -125,5 +125,177 @@ Next recovery options: 3. 継続できない場合は、作成済み worktree/branch を cleanup し、scope blocker を記録したまま人間判断待ちにする。 No further implementation side effects were performed after the failed spawn. + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + +--- + + + +## 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. + + + +## 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. + +--- + + + +## 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. --- diff --git a/crates/pod/src/feature.rs b/crates/pod/src/feature.rs index 5c903eb7..58a8ec86 100644 --- a/crates/pod/src/feature.rs +++ b/crates/pod/src/feature.rs @@ -1,8 +1,8 @@ //! Feature contribution registry for Pod-hosted builtin/plugin modules. //! //! This module defines the Pod-side feature boundary used to collect -//! descriptor metadata, host authority requests, tool contributions, safe hook -//! contributions, background task declarations, and service declarations before +//! descriptor metadata, tool contributions, safe hook contributions, background +//! task declarations, and service declarations before //! installing them into the existing Worker/HookRegistry host surfaces. //! //! The first implementation slice is intentionally host-mediated and @@ -69,26 +69,6 @@ pub enum FeatureRuntimeKind { 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. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -99,82 +79,6 @@ pub enum FeatureHookPoint { 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) -> Self { - Self { - authority, - required: true, - reason: reason.into(), - } - } - - pub fn optional(authority: HostAuthority, reason: impl Into) -> 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, - denied: Vec, -} - -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) { - 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 /// carried by [`ToolContribution`] during installation. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -192,12 +96,10 @@ impl ToolDeclaration { } } -/// Executable tool contribution wrapper. Host-authority requirements are optional -/// per-tool gates for privileged host APIs, not permission to contribute a tool. +/// Executable tool contribution wrapper. pub struct ToolContribution { name: String, definition: ToolDefinition, - required_host_authorities: Vec, } impl ToolContribution { @@ -205,18 +107,9 @@ impl ToolContribution { Self { name: name.into(), definition, - required_host_authorities: Vec::new(), } } - pub fn with_required_host_authorities( - mut self, - required_host_authorities: Vec, - ) -> Self { - self.required_host_authorities = required_host_authorities; - self - } - pub fn name(&self) -> &str { &self.name } @@ -410,7 +303,6 @@ pub struct FeatureDescriptor { pub display_name: String, pub version: String, pub description: String, - pub requested_host_authorities: Vec, pub tools: Vec, pub hooks: Vec, pub background_tasks: Vec, @@ -426,7 +318,6 @@ impl FeatureDescriptor { display_name: display_name.into(), version: env!("CARGO_PKG_VERSION").into(), description: String::new(), - requested_host_authorities: Vec::new(), tools: Vec::new(), hooks: Vec::new(), background_tasks: Vec::new(), @@ -440,11 +331,6 @@ impl FeatureDescriptor { 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 { self.tools.push(tool); self @@ -543,7 +429,6 @@ pub struct FeatureInstallReport { pub feature_id: FeatureId, pub runtime: FeatureRuntimeKind, pub installed: bool, - pub host_authority_grants: HostAuthorityGrantSet, pub installed_tools: Vec, pub installed_hooks: Vec, pub declared_background_tasks: Vec, @@ -554,12 +439,11 @@ pub struct FeatureInstallReport { } impl FeatureInstallReport { - fn new(descriptor: &FeatureDescriptor, host_authority_grants: HostAuthorityGrantSet) -> Self { + fn new(descriptor: &FeatureDescriptor) -> Self { Self { feature_id: descriptor.id.clone(), runtime: descriptor.runtime.clone(), installed: false, - host_authority_grants, installed_tools: Vec::new(), installed_hooks: Vec::new(), declared_background_tasks: Vec::new(), @@ -653,38 +537,14 @@ fn reject_undeclared_contribution( error } -fn require_host_authority( - host_authority_grants: &HostAuthorityGrantSet, - report: &mut FeatureInstallReport, - kind: FeatureContributionKind, - name: impl Into, - 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 /// the boundary without implementing a new event channel. pub struct FeatureNotificationSink<'a> { - host_authority_grants: &'a HostAuthorityGrantSet, report: &'a mut FeatureInstallReport, } impl FeatureNotificationSink<'_> { pub fn notify_model(&mut self, message: impl Into) -> Result<(), FeatureInstallError> { - require_host_authority( - self.host_authority_grants, - self.report, - FeatureContributionKind::Notification, - "notify_model", - &HostAuthority::ModelNotification, - )?; let message = message.into(); self.report.diagnostics.push(FeatureDiagnostic::warning(format!( "model notification requested during feature installation but no durable Notify host is attached: {message}" @@ -744,7 +604,6 @@ impl FeatureDiagnosticSink<'_> { pub struct ToolContributionRegistrar<'a> { feature_id: &'a FeatureId, declarations: &'a FeatureContributionDeclarations, - host_authority_grants: &'a HostAuthorityGrantSet, pending_tools: &'a mut Vec, installed_tool_names: &'a mut HashMap, 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) { let error = FeatureInstallError::DuplicateToolName { tool: model_visible_name.clone(), @@ -951,7 +800,6 @@ impl FeatureServiceRegistrar<'_> { pub struct FeatureInstallContext<'a> { feature_id: &'a FeatureId, declarations: &'a FeatureContributionDeclarations, - host_authority_grants: &'a HostAuthorityGrantSet, pending_tools: &'a mut Vec, installed_tool_names: &'a mut HashMap, hook_builder: &'a mut HookRegistryBuilder, @@ -964,15 +812,10 @@ impl FeatureInstallContext<'_> { self.feature_id } - pub fn host_authority_grants(&self) -> &HostAuthorityGrantSet { - self.host_authority_grants - } - pub fn tools(&mut self) -> ToolContributionRegistrar<'_> { ToolContributionRegistrar { feature_id: self.feature_id, declarations: self.declarations, - host_authority_grants: self.host_authority_grants, pending_tools: self.pending_tools, installed_tool_names: self.installed_tool_names, report: self.report, @@ -1007,7 +850,6 @@ impl FeatureInstallContext<'_> { pub fn notifications(&mut self) -> FeatureNotificationSink<'_> { FeatureNotificationSink { - host_authority_grants: self.host_authority_grants, report: self.report, } } @@ -1107,10 +949,8 @@ impl FeatureRegistryBuilder { let mut seen_features = HashSet::new(); 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 mut report = FeatureInstallReport::new(&descriptor, host_authority_grants.clone()); + let mut report = FeatureInstallReport::new(&descriptor); if !seen_features.insert(descriptor.id.clone()) { report.diagnostics.push(FeatureDiagnostic::error(format!( @@ -1126,13 +966,6 @@ impl FeatureRegistryBuilder { 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; for requirement in descriptor.requires_services.iter().cloned() { if service_registry.provides(&requirement.id) { @@ -1192,7 +1025,6 @@ impl FeatureRegistryBuilder { let mut context = FeatureInstallContext { feature_id: &descriptor.id, declarations: &declarations, - host_authority_grants: &host_authority_grants, pending_tools, installed_tool_names: &mut installed_tool_names, hook_builder, @@ -1256,8 +1088,6 @@ pub enum FeatureInstallError { first_feature: String, duplicate_feature: String, }, - #[error("feature host authority denied: {0}")] - HostAuthorityDenied(String), #[error("feature install failed: {0}")] Install(String), } @@ -1335,7 +1165,7 @@ mod tests { } #[test] - fn descriptor_contributions_and_empty_host_authority_grants_are_recorded() { + fn descriptor_contributions_are_recorded() { let descriptor = FeatureDescriptor::builtin("dummy", "Dummy") .with_tool(ToolDeclaration::new("Dummy", "dummy tool")) .with_background_task(BackgroundTaskDeclaration::descriptor_only( @@ -1358,7 +1188,6 @@ mod tests { assert!(feature_report.installed); assert_eq!(feature_report.installed_tools, vec!["Dummy"]); assert_eq!(feature_report.declared_background_tasks[0].name, "daily"); - assert!(feature_report.host_authority_grants.denied().is_empty()); } #[test] @@ -1421,79 +1250,6 @@ mod tests { 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, - } - - 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] fn stateful_tool_definition_is_materialized_once_for_report_and_worker() { struct StatefulToolFeature { @@ -1790,7 +1546,7 @@ mod tests { } #[test] - fn background_task_declaration_is_not_host_authority_gated() { + fn background_task_declaration_is_descriptor_contribution() { let descriptor = FeatureDescriptor::builtin("background", "Background") .with_background_task(BackgroundTaskDeclaration::descriptor_only( "declared-task", @@ -1811,7 +1567,7 @@ mod tests { } #[test] - fn service_provider_declaration_is_not_host_authority_gated() { + fn service_provider_declaration_is_descriptor_contribution() { let service = ServiceId::builtin("declared-service"); let descriptor = FeatureDescriptor::builtin("service", "Service").with_provided_service( ServiceDeclaration::new(service.clone(), "1", "descriptor contribution"), @@ -1829,7 +1585,7 @@ mod tests { } #[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 tool_names: Vec<_> = descriptor .tools @@ -1845,7 +1601,6 @@ mod tests { assert_eq!(descriptor.id.as_str(), "builtin:task-tools"); assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin); - assert!(descriptor.requested_host_authorities.is_empty()); assert_eq!( hook_points, vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall] @@ -1860,7 +1615,7 @@ mod tests { } #[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 pending_tools = Vec::new(); let mut builder = FeatureRegistryBuilder::new(); @@ -1882,10 +1637,6 @@ mod tests { assert_eq!(report.reports.len(), 1); 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].diagnostics.is_empty()); assert_eq!(report.reports[0].installed_hooks.len(), 2); diff --git a/crates/pod/src/feature/builtin/task/mod.rs b/crates/pod/src/feature/builtin/task/mod.rs index d6d8c725..abf6741d 100644 --- a/crates/pod/src/feature/builtin/task/mod.rs +++ b/crates/pod/src/feature/builtin/task/mod.rs @@ -35,9 +35,8 @@ const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"]; /// /// The returned module contributes `TaskCreate`, `TaskUpdate`, `TaskGet`, and /// `TaskList` through descriptor-approved tool registration, plus built-in hooks -/// that maintain Task-reminder state. It does not request sandbox/external-plugin -/// host authorities; normal ToolRegistry and PreToolCall permission policy still -/// applies at call time. +/// that maintain Task-reminder state. Normal ToolRegistry and PreToolCall +/// permission policy still applies at call time. pub fn task_tools_feature() -> TaskFeature { TaskFeature::new() } diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index f9cf2f05..c3bfd804 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -18,14 +18,13 @@ use ticket::{ use crate::feature::{ FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, - FeatureModule, HostAuthority, HostAuthorityRequest, ToolContribution, ToolDeclaration, + FeatureModule, ToolContribution, ToolDeclaration, }; const FEATURE_ID: &str = "ticket"; const FEATURE_NAME: &str = "Ticket tools"; 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."; -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)] pub enum TicketFeatureAccess { @@ -150,12 +149,6 @@ impl TicketFeature { names } - fn authority(&self) -> HostAuthority { - HostAuthority::TicketBackend { - root: self.backend_root.display().to_string(), - } - } - fn usable_backend_root(&self) -> Result { let root = self .backend_root @@ -171,11 +164,7 @@ impl TicketFeature { impl FeatureModule for TicketFeature { fn descriptor(&self) -> FeatureDescriptor { let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME) - .with_description(FEATURE_DESCRIPTION) - .with_host_authority(HostAuthorityRequest::required( - self.authority(), - AUTHORITY_REASON, - )); + .with_description(FEATURE_DESCRIPTION); let enabled_tool_names = self.enabled_tool_names(); for name in &enabled_tool_names { descriptor = descriptor.with_tool(ToolDeclaration::new( @@ -207,7 +196,6 @@ impl FeatureModule for TicketFeature { return Ok(()); } }; - let authority = self.authority(); let backend = LocalTicketBackend::new(usable_root) .with_record_language(self.record_language.as_deref()); let allowed_tool_names = self.enabled_tool_names(); @@ -221,10 +209,7 @@ impl FeatureModule for TicketFeature { { continue; } - tools.register( - ToolContribution::new(name, definition) - .with_required_host_authorities(vec![authority.clone()]), - )?; + tools.register(ToolContribution::new(name, definition))?; } Ok(()) } @@ -284,7 +269,7 @@ mod tests { } #[test] - fn descriptor_declares_ticket_tools_and_backend_authority() { + fn descriptor_declares_ticket_tools() { let temp = TempDir::new().unwrap(); let feature = ticket_tools_feature(temp.path()); let descriptor = feature.descriptor(); @@ -299,11 +284,6 @@ mod tests { .collect::>(), TICKET_TOOL_NAMES ); - assert_eq!(descriptor.requested_host_authorities.len(), 1); - assert!(matches!( - descriptor.requested_host_authorities[0].authority, - HostAuthority::TicketBackend { .. } - )); } #[test] @@ -321,7 +301,6 @@ mod tests { .collect::>(), TICKET_READ_ONLY_TOOL_NAMES ); - assert_eq!(descriptor.requested_host_authorities.len(), 1); } #[test] diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index fdb081c6..ad0ef355 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -35,6 +35,9 @@ pub fn ticket_config_scaffold() -> String { out.push_str( "\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/.\n# [orchestration]\n# branch = \"orchestration/\"\n", + ); for role in TicketRole::ALL { out.push_str(&format!( "\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", @@ -67,15 +70,110 @@ pub enum TicketConfigError { pub struct TicketConfig { pub backend: TicketBackendConfig, pub ticket: TicketRecordConfig, + pub orchestration: TicketOrchestrationConfig, pub roles: TicketRoleProfiles, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TicketOrchestrationConfig { + pub branch: Option, +} + +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) -> Result { + 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(deserializer: D) -> Result + 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 { pub fn default_for_workspace(workspace_root: impl AsRef) -> Self { let workspace_root = workspace_root.as_ref(); Self { backend: TicketBackendConfig::default_for_workspace(workspace_root), ticket: TicketRecordConfig::default(), + orchestration: TicketOrchestrationConfig::default(), roles: TicketRoleProfiles::default(), } } @@ -528,9 +626,26 @@ struct RawTicketConfig { #[serde(default)] ticket: RawTicketRecordConfig, #[serde(default)] + orchestration: RawTicketOrchestrationConfig, + #[serde(default)] roles: BTreeMap, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTicketOrchestrationConfig { + #[serde(default)] + branch: Option, +} + +impl RawTicketOrchestrationConfig { + fn resolve(self) -> TicketOrchestrationConfig { + TicketOrchestrationConfig { + branch: self.branch, + } + } +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawTicketRecordConfig { @@ -576,6 +691,7 @@ impl RawTicketConfig { } })?, ticket: self.ticket.resolve(), + orchestration: self.orchestration.resolve(), roles, }) } @@ -680,6 +796,7 @@ mod tests { temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH) ); assert_eq!(config.ticket_record_language(), None); + assert_eq!(config.orchestration.branch_name(), None); for role in TicketRole::ALL { let role_config = config.role(role); assert_eq!(role_config.profile.as_str(), "inherit"); @@ -701,6 +818,9 @@ root = "custom-tickets" [ticket] language = "Japanese" +[orchestration] +branch = "orchestration/custom-panel" + [roles.intake] profile = "project:intake" 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.ticket_record_language(), Some("Japanese")); + assert_eq!( + config.orchestration.branch_name(), + Some("orchestration/custom-panel") + ); assert_eq!( config.profile_for(TicketRole::Intake).as_str(), "project:intake" @@ -756,6 +880,9 @@ workflow = "multi-agent-workflow" assert!(scaffold.contains("provider = \"builtin:yoi_local\"")); assert!(scaffold.contains("root = \".yoi/tickets\"")); assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\"")); + assert!(scaffold.contains( + "# [orchestration]\n# branch = \"orchestration/\"" + )); for role in TicketRole::ALL { assert!(scaffold.contains(&format!("[roles.{role}]"))); assert!(scaffold.contains(&format!( @@ -773,6 +900,7 @@ workflow = "multi-agent-workflow" ) .unwrap(); assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); + assert_eq!(config.orchestration.branch_name(), None); for role in TicketRole::ALL { let role_config = config.role_launch_config(role).unwrap(); 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] fn role_table_without_profile_is_not_role_launch_ready() { let temp = TempDir::new().unwrap(); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index ba7a2315..34cdb099 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -27,8 +27,11 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; use serde::Serialize; use session_store::FsStore; -use ticket::config::TicketConfig; -use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState}; +use ticket::config::{GitBranchName, TicketConfig}; +use ticket::{ + LocalTicketBackend, MarkdownText, TicketBackend, TicketIdOrSlug, TicketStateChange, + TicketWorkflowState, +}; use tokio::net::UnixStream; use unicode_width::UnicodeWidthStr; @@ -62,6 +65,7 @@ const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400; const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3); const MULTI_POD_POLL_INTERVAL: Duration = Duration::from_millis(1_500); const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100); +const PANEL_READY_REFINEMENT_MAX_INSTRUCTION_CHARS: usize = 4_000; #[derive(Debug)] pub(crate) enum MultiPodError { @@ -210,6 +214,42 @@ pub(crate) async fn run( } next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } + MultiPodAction::ReturnReadyTicketToPlanning(request) => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "panel", + "action_requested", + serde_json::json!({ "action": "return_ready_ticket_to_planning" }), + ); + pending_reload.abort(); + pending_queue_attention_notice.abort(); + terminal.draw(|f| draw(f, app))?; + match dispatch_ready_ticket_planning_return(request).await { + Ok(outcome) => { + match app.finish_ready_ticket_planning_return_success(outcome) { + ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request) => { + terminal.draw(|f| draw(f, app))?; + let planning_notice = app.notice.clone().unwrap_or_default(); + let result = launch_intake_with_handoff(request).await; + app.finish_ready_ticket_planning_return_with_intake_launch( + planning_notice, + result, + ); + } + ReadyTicketPlanningReturnAfterMutation::OpenClaim(request) => { + terminal.draw(|f| draw(f, app))?; + return Ok(MultiPodOutcome::Open(request)); + } + ReadyTicketPlanningReturnAfterMutation::None => {} + } + } + Err(error) => app.finish_ready_ticket_planning_return_error(error), + } + if pending_reload.start(OrchestratorLifecycleMode::Observe) { + app.refreshing = true; + } + next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + } MultiPodAction::LaunchIntake(request) => { #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( @@ -456,6 +496,45 @@ pub(crate) enum IntakeRegistryUpdate { ticket_slug: Option, pod_name: String, }, + ClaimLaunchedTicket { + registry_root: PathBuf, + ticket_id: String, + ticket_slug: Option, + }, +} + +#[derive(Debug)] +pub(crate) struct ReadyTicketPlanningReturnRequest { + workspace_root: PathBuf, + ticket_id: String, + user_instruction: String, + followup: ReadyTicketPlanningReturnFollowup, +} + +#[derive(Debug)] +pub(crate) enum ReadyTicketPlanningReturnFollowup { + LaunchIntake(IntakeLaunchRequest), + NotifyLiveClaimedIntake { + pod_name: String, + socket_path: PathBuf, + }, + OpenRestorableClaimedIntake(OpenPodRequest), + BlockedByStaleClaim { + pod_name: String, + }, +} + +#[derive(Debug)] +struct ReadyTicketPlanningReturnOutcome { + notice: String, + followup: ReadyTicketPlanningReturnAfterMutation, +} + +#[derive(Debug)] +enum ReadyTicketPlanningReturnAfterMutation { + LaunchIntake(IntakeLaunchRequest), + OpenClaim(OpenPodRequest), + None, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -608,7 +687,8 @@ async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunc options, ) .await?; - let registry_warning = commit_intake_registry_update(request.registry_update); + let registry_warning = + commit_intake_registry_update(request.registry_update, Some(&launch.plan.pod_name)); let peer_registration = match (orchestrator_pod, skip_warning) { (_, Some(warning)) => warning, (Some(orchestrator_pod), None) if launch.pre_run_warnings.is_empty() => { @@ -633,7 +713,10 @@ async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunc }) } -fn commit_intake_registry_update(update: IntakeRegistryUpdate) -> Option { +fn commit_intake_registry_update( + update: IntakeRegistryUpdate, + launched_pod_name: Option<&str>, +) -> Option { match update { IntakeRegistryUpdate::RecordSession { registry_root, @@ -670,6 +753,29 @@ fn commit_intake_registry_update(update: IntakeRegistryUpdate) -> Option "local Ticket Intake claim could not be committed after launch acceptance: {error}" ))), }, + IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root, + ticket_id, + ticket_slug, + } => { + let Some(pod_name) = launched_pod_name else { + return Some( + "local Ticket Intake claim could not be committed after launch acceptance: missing launched Pod name" + .to_string(), + ); + }; + match PanelRegistryStore::from_root(registry_root).claim_ticket( + &ticket_id, + ticket_slug.as_deref(), + pod_name, + TicketRole::Intake.as_str(), + ) { + Ok(TicketClaimResult::Claimed) | Ok(TicketClaimResult::AlreadyOwned(_)) => None, + Err(error) => Some(bounded_panel_diagnostic(format!( + "local Ticket Intake claim could not be committed after launch acceptance: {error}" + ))), + } + } } } @@ -1171,12 +1277,41 @@ impl MultiPodApp { } fn handle_mouse_event(&mut self, event: MouseEvent) -> bool { - if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { - return false; - } if self.panel_diagnostic_open { return false; } + match event.kind { + MouseEventKind::ScrollDown => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "panel", + "mouse_wheel", + serde_json::json!({ + "column": event.column, + "row": event.row, + "direction": "down", + }), + ); + self.select_next(); + return true; + } + MouseEventKind::ScrollUp => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "panel", + "mouse_wheel", + serde_json::json!({ + "column": event.column, + "row": event.row, + "direction": "up", + }), + ); + self.select_prev(); + return true; + } + MouseEventKind::Down(MouseButton::Left) => {} + _ => return false, + } let Some(key) = self .row_hit_boxes .iter() @@ -1715,6 +1850,209 @@ impl MultiPodApp { }) } + pub(crate) fn prepare_ready_ticket_planning_return( + &mut self, + ) -> Option { + if self.sending { + self.notice = Some( + "Ticket refinement return is already in progress; wait for it to finish before retrying." + .to_string(), + ); + return None; + } + if !self + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + self.composer_target = ComposerTarget::Companion; + self.notice = Some( + "Ticket Intake target is unavailable without usable Ticket config.".to_string(), + ); + return None; + } + let body = Segment::flatten_to_text(&self.input.submit_segments()); + let user_instruction = bounded_refinement_instruction(body.trim()); + if user_instruction.is_empty() { + self.notice = Some( + "Type refinement instructions with the Ticket Planning target before returning a ready Ticket to planning." + .to_string(), + ); + return None; + } + let row = match self.selected_panel_row() { + Some(row) if row.is_ticket_action() => row, + Some(row) if row.ticket.is_some() => { + self.notice = + Some("Selected Ticket row has no refinement return action.".to_string()); + return None; + } + _ => { + self.notice = + Some("Select a ready Ticket row before returning it to planning.".to_string()); + return None; + } + }; + if row.next_action != Some(NextUserAction::Queue) { + self.notice = Some( + "Only ready Ticket rows can be returned to planning from the Ticket Planning target." + .to_string(), + ); + return None; + } + let Some(ticket) = row.ticket.as_ref() else { + self.notice = + Some("Select a ready Ticket row before returning it to planning.".to_string()); + return None; + }; + let ticket_id = ticket.id.clone(); + if ticket.workflow_state != TicketWorkflowState::Ready { + self.notice = Some(format!( + "Ticket {} is {}; expected ready before returning to planning.", + ticket_id, + ticket.workflow_state.as_str() + )); + return None; + } + + let workspace_root = current_workspace_root(); + let store = match PanelRegistryStore::default_for_workspace(&workspace_root) { + Ok(store) => store, + Err(error) => { + self.notice = Some(format!("Ticket Intake registry unavailable: {error}")); + return None; + } + }; + let followup = match store.claim_for_ticket(&ticket_id) { + Ok(Some(claim)) => match local_claim_status_for_pod(&claim.pod_name, &self.list) { + TicketLocalClaimStatus::Live => match self + .list + .entries + .iter() + .find(|entry| entry.name == claim.pod_name) + .and_then(PodListEntry::attach_socket_path) + { + Some(socket_path) => { + ReadyTicketPlanningReturnFollowup::NotifyLiveClaimedIntake { + pod_name: claim.pod_name, + socket_path: socket_path.to_path_buf(), + } + } + None => ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { + pod_name: claim.pod_name, + }, + }, + TicketLocalClaimStatus::Restorable => { + ReadyTicketPlanningReturnFollowup::OpenRestorableClaimedIntake(OpenPodRequest { + pod_name: claim.pod_name, + socket_override: None, + }) + } + TicketLocalClaimStatus::Stale => { + ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { + pod_name: claim.pod_name, + } + } + }, + Ok(None) => { + let mut context = + TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake); + context.ticket = Some(TicketRef::id(ticket_id.clone())); + context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction( + &ticket_id, + &user_instruction, + )); + let peer_registration = self.prepare_intake_peer_registration(&mut context); + ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { + context, + runtime_command: self.runtime_command.clone(), + peer_registration, + registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root: store.root().to_path_buf(), + ticket_id: ticket_id.clone(), + ticket_slug: None, + }, + }) + } + Err(error) => { + self.notice = Some(format!("Ticket claim diagnostic required: {error}")); + return None; + } + }; + + self.sending = true; + self.notice = Some(format!( + "Returning ready Ticket {} to planning for refinement…", + ticket_id + )); + Some(ReadyTicketPlanningReturnRequest { + workspace_root, + ticket_id, + user_instruction, + followup, + }) + } + + fn finish_ready_ticket_planning_return_error(&mut self, error: TicketActionError) { + self.sending = false; + self.notice = Some(match error { + TicketActionError::Stale(message) => { + self.set_panel_diagnostic("Ticket planning return rejected", message) + } + TicketActionError::BackendConfig(error) | TicketActionError::Ticket(error) => { + self.set_panel_diagnostic("Ticket planning return failed", error) + } + }); + } + + fn finish_ready_ticket_planning_return_success( + &mut self, + outcome: ReadyTicketPlanningReturnOutcome, + ) -> ReadyTicketPlanningReturnAfterMutation { + self.sending = false; + self.input.clear(); + self.notice = Some(outcome.notice); + outcome.followup + } + + fn finish_ready_ticket_planning_return_with_intake_launch( + &mut self, + planning_notice: String, + result: IntakeLaunchResult, + ) { + self.sending = false; + self.input.clear(); + match result { + Ok(result) => { + let pod_name = result.launch.plan.pod_name; + let peer_notice = match result.peer_registration { + IntakePeerRegistrationStatus::Registered { orchestrator_pod } => { + format!(" Handoff peer registered with {orchestrator_pod}.") + } + IntakePeerRegistrationStatus::Warning { message } => { + format!(" Handoff warning: {message}") + } + }; + let registry_notice = result + .registry_warning + .map(|warning| format!(" Registry warning: {warning}")) + .unwrap_or_default(); + self.notice = Some(bounded_panel_diagnostic(format!( + "{planning_notice} Launched Ticket Intake Pod {pod_name}.{peer_notice}{registry_notice}" + ))); + } + Err(error) => { + self.notice = Some(self.set_panel_diagnostic( + "Ticket Intake launch failed after planning return", + format!( + "{planning_notice} Intake launch/restore failed after Ticket was returned to planning; instruction was recorded in the Ticket thread. {}", + error + ), + )); + } + } + } + pub(crate) fn finish_intake_launch(&mut self, result: IntakeLaunchResult) { self.sending = false; match result { @@ -1828,6 +2166,14 @@ impl MultiPodApp { .unwrap_or(MultiPodAction::None) } KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open, + KeyCode::Enter + if self.composer_target == ComposerTarget::TicketIntake + && self.selected_ticket_action() == Some(NextUserAction::Queue) => + { + self.prepare_ready_ticket_planning_return() + .map(MultiPodAction::ReturnReadyTicketToPlanning) + .unwrap_or(MultiPodAction::None) + } KeyCode::Enter if self.composer_target == ComposerTarget::TicketIntake => self .prepare_intake_launch() .map(MultiPodAction::LaunchIntake) @@ -1850,6 +2196,7 @@ enum MultiPodAction { Quit, Open, DispatchTicketAction(TicketActionRequest), + ReturnReadyTicketToPlanning(ReadyTicketPlanningReturnRequest), LaunchIntake(IntakeLaunchRequest), SendCompanion(CompanionSendRequest), } @@ -2170,17 +2517,43 @@ struct OrchestrationWorktreeReady { status: OrchestrationWorktreeStatus, } -fn orchestration_worktree_layout(workspace_root: &Path) -> OrchestrationWorktreeLayout { +fn orchestration_worktree_layout_for_branch( + workspace_root: &Path, + branch: String, +) -> OrchestrationWorktreeLayout { let stem = workspace_orchestrator_pod_name(workspace_root); OrchestrationWorktreeLayout { path: workspace_root .join(".worktree") .join("orchestration") .join(&stem), - branch: format!("orchestration/{stem}"), + branch, } } +fn orchestration_worktree_layout(workspace_root: &Path) -> OrchestrationWorktreeLayout { + let stem = workspace_orchestrator_pod_name(workspace_root); + orchestration_worktree_layout_for_branch(workspace_root, format!("orchestration/{stem}")) +} + +fn resolved_orchestration_worktree_layout( + workspace_root: &Path, +) -> Result { + let config = TicketConfig::load_workspace(workspace_root) + .map_err(|err| format!("failed to load ticket config for orchestration branch: {err}"))?; + let branch = if let Some(branch) = config.orchestration.branch_name() { + branch.to_string() + } else { + orchestration_worktree_layout(workspace_root).branch + }; + GitBranchName::new(branch.clone()) + .map_err(|message| format!("invalid orchestration branch `{branch}`: {message}"))?; + Ok(orchestration_worktree_layout_for_branch( + workspace_root, + branch, + )) +} + fn build_orchestrator_launch_context( original_workspace_root: &Path, orchestration_workspace_root: &Path, @@ -2204,7 +2577,7 @@ fn build_orchestrator_launch_context( fn ensure_orchestration_worktree( workspace_root: &Path, ) -> Result { - let layout = orchestration_worktree_layout(workspace_root); + let layout = resolved_orchestration_worktree_layout(workspace_root)?; if layout.path.exists() { if !layout.path.is_dir() { return Err(format!( @@ -2267,7 +2640,7 @@ fn ensure_orchestration_worktree( fn prepare_orchestration_worktree_for_restore( workspace_root: &Path, ) -> Result { - let layout = orchestration_worktree_layout(workspace_root); + let layout = resolved_orchestration_worktree_layout(workspace_root)?; if !layout.path.exists() { return Err(format!( "orchestration worktree is missing; cannot restore existing Pod state: {}", @@ -2503,8 +2876,9 @@ async fn orchestrator_lifecycle( pod_name, OrchestratorPanelStatus::Restored, Some(format!( - "restored existing Pod state in orchestration worktree {}", - worktree.layout.path.display() + "restored existing Pod state in orchestration worktree {} on branch {}", + worktree.layout.path.display(), + worktree.layout.branch )), )) .mark_reload() @@ -3125,6 +3499,132 @@ async fn dispatch_orchestrator_queue_attention_notice( } } +fn bounded_refinement_instruction(input: &str) -> String { + bounded_progress_text(input, PANEL_READY_REFINEMENT_MAX_INSTRUCTION_CHARS) + .trim() + .to_string() +} + +fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String { + format!( + "Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n" + ) +} + +fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String { + format!( + "Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read TicketShow body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}" + ) +} + +fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String { + format!( + "Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}" + ) +} + +async fn dispatch_ready_ticket_planning_return( + request: ReadyTicketPlanningReturnRequest, +) -> Result { + match ticket_config_availability(&request.workspace_root) { + TicketConfigAvailability::Usable => {} + TicketConfigAvailability::Absent => { + return Err(TicketActionError::Stale( + "Ticket config is absent; workspace panel no longer exposes Ticket actions" + .to_string(), + )); + } + TicketConfigAvailability::Unusable(message) => { + return Err(TicketActionError::Stale(format!( + "Ticket config is unusable; workspace panel no longer exposes Ticket actions: {message}" + ))); + } + } + let config = TicketConfig::load_workspace(&request.workspace_root) + .map_err(|error| TicketActionError::BackendConfig(error.to_string()))?; + let backend = LocalTicketBackend::new(config.backend_root()) + .with_record_language(config.ticket_record_language()); + let id = TicketIdOrSlug::Id(request.ticket_id.clone()); + let ticket = backend + .show(id.clone()) + .map_err(|error| TicketActionError::Ticket(error.to_string()))?; + if ticket.meta.workflow_state != TicketWorkflowState::Ready { + return Err(TicketActionError::Stale(format!( + "Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.", + ticket.meta.id, + ticket.meta.workflow_state.as_str() + ))); + } + let mut change = TicketStateChange::new( + TicketWorkflowState::Ready.as_str(), + TicketWorkflowState::Planning.as_str(), + "panel_return_to_planning", + MarkdownText::from(build_ready_ticket_refinement_thread_body( + &ticket.meta.id, + &request.user_instruction, + )), + ); + change.author = Some("workspace-panel".to_string()); + backend + .set_workflow_state(id, change) + .map_err(|error| TicketActionError::Ticket(error.to_string()))?; + + let notice = match request.followup { + ReadyTicketPlanningReturnFollowup::LaunchIntake(request) => { + ReadyTicketPlanningReturnOutcome { + notice: format!( + "Ticket {} returned to planning for refinement; launching Ticket Intake…", + ticket.meta.id + ), + followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request), + } + } + ReadyTicketPlanningReturnFollowup::NotifyLiveClaimedIntake { + pod_name, + socket_path, + } => { + let message = + build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction); + match send_notify_only(&socket_path, message, true).await { + Ok(()) => ReadyTicketPlanningReturnOutcome { + notice: format!( + "Ticket {} returned to planning for refinement; notified live Intake Pod {}.", + ticket.meta.id, pod_name + ), + followup: ReadyTicketPlanningReturnAfterMutation::None, + }, + Err(error) => ReadyTicketPlanningReturnOutcome { + notice: bounded_panel_diagnostic(format!( + "Ticket {} returned to planning and instruction was recorded, but notifying Intake Pod {} failed: {}", + ticket.meta.id, pod_name, error + )), + followup: ReadyTicketPlanningReturnAfterMutation::None, + }, + } + } + ReadyTicketPlanningReturnFollowup::OpenRestorableClaimedIntake(request) => { + let pod_name = request.pod_name.clone(); + ReadyTicketPlanningReturnOutcome { + notice: format!( + "Ticket {} returned to planning for refinement; opening/restoring claimed Intake Pod {}…", + ticket.meta.id, pod_name + ), + followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request), + } + } + ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { pod_name } => { + ReadyTicketPlanningReturnOutcome { + notice: bounded_panel_diagnostic(format!( + "Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Pod.", + ticket.meta.id, pod_name + )), + followup: ReadyTicketPlanningReturnAfterMutation::None, + } + } + }; + Ok(notice) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketActionOutcome { notice: String, @@ -3406,7 +3906,15 @@ fn prepare_panel_queue_handoff( queue_check_failed("root-git-user", ticket_id, &root_top_level, message) })?; - let orchestration = orchestration_worktree_layout(&root_top_level); + let orchestration = + resolved_orchestration_worktree_layout(&root_top_level).map_err(|message| { + queue_check_failed( + "orchestration-branch-config", + ticket_id, + &root_top_level, + message, + ) + })?; if !orchestration.path.exists() { return Err(queue_check_failed( "orchestration-worktree-identity", @@ -4938,6 +5446,11 @@ fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: R fn composer_enter_status_text(app: &MultiPodApp) -> String { match app.composer_target() { ComposerTarget::Companion => companion_enter_status_text(app), + ComposerTarget::TicketIntake + if app.selected_ticket_action() == Some(NextUserAction::Queue) => + { + "return selected ready Ticket to planning".to_string() + } ComposerTarget::TicketIntake => "launch Intake with composer text".to_string(), } } @@ -4945,6 +5458,9 @@ fn composer_enter_status_text(app: &MultiPodApp) -> String { fn composer_enter_actionbar_text(app: &MultiPodApp) -> String { match app.composer_target() { ComposerTarget::Companion => companion_enter_actionbar_text(app), + ComposerTarget::TicketIntake if app.selected_ticket_action() == Some(NextUserAction::Queue) => { + "Ticket Intake target: Enter records instructions and returns selected ready Ticket to planning".to_string() + } ComposerTarget::TicketIntake => { "Ticket Intake target: Enter launches Intake with composer text".to_string() } @@ -5050,7 +5566,11 @@ fn actionbar_left_text(app: &MultiPodApp) -> String { .to_string() } ComposerTarget::TicketIntake => { - "Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string() + if app.selected_ticket_action() == Some(NextUserAction::Queue) { + "Composer target: Ticket Intake; text + Enter returns selected ready Ticket to planning".to_string() + } else { + "Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string() + } } } } @@ -5219,6 +5739,94 @@ mod tests { assert!(created.layout.path.join("dirty.txt").exists()); } + #[test] + fn ensure_and_restore_use_configured_orchestration_branch() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/custom-panel" +"#, + ); + run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); + + let resolved = resolved_orchestration_worktree_layout(&root).unwrap(); + assert_eq!(resolved.branch, "orchestration/custom-panel"); + assert!( + resolved + .path + .ends_with(".worktree/orchestration/repo-orchestrator") + ); + + let created = ensure_orchestration_worktree(&root).unwrap(); + assert_eq!(created.status, OrchestrationWorktreeStatus::Created); + assert_eq!(created.layout, resolved); + let branch = + run_test_git_output(&created.layout.path, &["branch", "--show-current"]).unwrap(); + assert_eq!(branch.trim(), "orchestration/custom-panel"); + + let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); + assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); + assert_eq!(restored.layout, created.layout); + } + + #[test] + fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + std::fs::create_dir_all(&root).unwrap(); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/bad:branch" +"#, + ); + + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("failed to load ticket config")); + assert!(err.contains("git branch name")); + assert!(!root.join(".worktree").exists()); + } + + #[test] + fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/custom-panel" +"#, + ); + run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); + let layout = resolved_orchestration_worktree_layout(&root).unwrap(); + run_test_git( + &root, + &[ + "worktree", + "add", + &layout.path.display().to_string(), + "-b", + "orchestration/other-panel", + "HEAD", + ], + ) + .unwrap(); + + let err = prepare_orchestration_worktree_for_restore(&root).unwrap_err(); + assert!(err.contains("expected orchestration/custom-panel")); + let branch = run_test_git_output(&layout.path, &["branch", "--show-current"]).unwrap(); + assert_eq!(branch.trim(), "orchestration/other-panel"); + } + #[test] fn restore_uses_existing_orchestration_worktree_even_when_dirty() { let temp = TempDir::new().unwrap(); @@ -5309,6 +5917,12 @@ mod tests { assert!(layout.path.join("unrelated.txt").exists()); } + fn write_test_ticket_config(root: &Path, content: &str) { + let config_dir = root.join(".yoi"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap(); + } + fn init_test_repo(root: &Path) { std::fs::create_dir_all(root).unwrap(); run_test_git(root, &["init"]).unwrap(); @@ -5340,6 +5954,22 @@ mod tests { run_git_command(command, "run test git") } + fn run_test_git_output(root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .map_err(|error| format!("could not run test git: {error}"))?; + if !output.status.success() { + return Err(format!( + "git failed to run test git: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo}; use std::fs; use tempfile::TempDir; @@ -5415,6 +6045,168 @@ mod tests { } } + fn planning_return_request( + temp: &TempDir, + ticket_id: String, + instruction: &str, + ) -> ReadyTicketPlanningReturnRequest { + ReadyTicketPlanningReturnRequest { + workspace_root: temp.path().to_path_buf(), + ticket_id, + user_instruction: instruction.to_string(), + followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { + pod_name: "stale-intake".to_string(), + }, + } + } + + #[tokio::test] + async fn ready_ticket_planning_return_records_instruction_and_returns_to_planning_without_queueing() + { + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-ready"); + + let outcome = dispatch_ready_ticket_planning_return(planning_return_request( + &temp, + ticket_id.clone(), + "please add acceptance detail before queueing", + )) + .await + .unwrap(); + + assert!(outcome.notice.contains("returned to planning")); + assert!(outcome.notice.contains("instruction was recorded")); + assert!(matches!( + outcome.followup, + ReadyTicketPlanningReturnAfterMutation::None + )); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); + assert!(ticket.meta.queued_by.is_none()); + assert!(ticket.meta.queued_at.is_none()); + let state_change = ticket + .events + .iter() + .find(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("state") + && event.from.as_deref() == Some("ready") + && event.to.as_deref() == Some("planning") + }) + .expect("ready -> planning state_changed event is recorded"); + assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); + assert!( + state_change + .body + .as_str() + .contains("please add acceptance detail") + ); + assert!(state_change.body.as_str().contains("not Queue routing")); + assert!( + state_change + .body + .as_str() + .contains("must not start implementation") + ); + } + + #[tokio::test] + async fn ready_ticket_planning_return_rejects_stale_non_ready_ticket() { + let (temp, ticket_id, backend) = + ticket_workspace("panel-refine-stale", TicketWorkflowState::Planning, |_| {}); + + let error = dispatch_ready_ticket_planning_return(planning_return_request( + &temp, + ticket_id.clone(), + "refine please", + )) + .await + .unwrap_err(); + + assert!(error.to_string().contains("expected ready")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); + assert!( + ticket + .events + .iter() + .all(|event| !(event.kind == TicketEventKind::StateChanged + && event.from.as_deref() == Some("ready") + && event.to.as_deref() == Some("planning"))) + ); + } + + #[test] + fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launch() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); + panel.rows.push(panel_test_ticket_row( + "20260608-000123-ready", + "Ready Ticket", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + let mut app = app_with_panel(empty_test_list(), panel); + app.cycle_composer_target(); + app.input.insert_str("clarify expected behavior"); + + let request = match app.handle_key(key(KeyCode::Enter)) { + MultiPodAction::ReturnReadyTicketToPlanning(request) => request, + _ => panic!("ready Ticket row with Ticket Intake text should return to planning"), + }; + + assert_eq!(request.ticket_id, "20260608-000123-ready"); + assert_eq!(request.user_instruction, "clarify expected behavior"); + assert!(matches!( + request.followup, + ReadyTicketPlanningReturnFollowup::LaunchIntake(_) + )); + assert!(app.sending); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Returning ready Ticket") + ); + assert_eq!(input_text(&app), "clarify expected behavior"); + } + + #[tokio::test] + async fn planning_return_with_launch_followup_changes_state_before_launch_followup() { + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-launch"); + let request = ReadyTicketPlanningReturnRequest { + workspace_root: temp.path().to_path_buf(), + ticket_id: ticket_id.clone(), + user_instruction: "launch intake after state change".to_string(), + followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { + context: TicketRoleLaunchContext::new( + temp.path().to_path_buf(), + TicketRole::Intake, + ), + runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), + peer_registration: IntakePeerRegistrationRequest::Skip { + reason: "test".to_string(), + }, + registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root: temp.path().join(".yoi/local-role-sessions"), + ticket_id: ticket_id.clone(), + ticket_slug: None, + }, + }), + }; + + let outcome = dispatch_ready_ticket_planning_return(request) + .await + .unwrap(); + + assert!(matches!( + outcome.followup, + ReadyTicketPlanningReturnAfterMutation::LaunchIntake(_) + )); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); + } + #[tokio::test] async fn ticket_queue_action_transitions_ready_ticket_and_authorizes_orchestrator_routing() { let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-queue"); @@ -7263,7 +8055,7 @@ mod tests { "holding a pending Intake registry update must not persist a Ticket claim" ); - assert!(commit_intake_registry_update(update.clone()).is_none()); + assert!(commit_intake_registry_update(update.clone(), None).is_none()); assert!( store .claim_for_ticket("20260608-000000-existing") @@ -7272,7 +8064,7 @@ mod tests { "the claim is persisted only by the post-acceptance commit step" ); - assert!(commit_intake_registry_update(update).is_none()); + assert!(commit_intake_registry_update(update, None).is_none()); let snapshot = store.snapshot().unwrap(); assert_eq!(snapshot.claims.len(), 1); assert_eq!(snapshot.sessions.len(), 1); @@ -7285,6 +8077,56 @@ mod tests { ); } + #[test] + fn intake_registry_claims_launched_ticket_with_accepted_pod_name() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("registry"); + let store = PanelRegistryStore::from_root(root.clone()); + let update = IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root: root, + ticket_id: "20260608-000000-ready".to_string(), + ticket_slug: None, + }; + + assert!(commit_intake_registry_update(update, Some("launched-intake")).is_none()); + + let claim = store + .claim_for_ticket("20260608-000000-ready") + .unwrap() + .expect("launched Intake Pod is claimed after accepted launch"); + assert_eq!(claim.pod_name, "launched-intake"); + let snapshot = store.snapshot().unwrap(); + assert_eq!(snapshot.claims.len(), 1); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim); + assert_eq!(snapshot.sessions[0].pod_name, "launched-intake"); + } + + #[test] + fn intake_registry_launched_ticket_claim_without_pod_name_is_diagnostic() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("registry"); + let store = PanelRegistryStore::from_root(root.clone()); + + let warning = commit_intake_registry_update( + IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root: root, + ticket_id: "20260608-000000-ready".to_string(), + ticket_slug: None, + }, + None, + ) + .expect("missing launched Pod name should be diagnostic"); + + assert!(warning.contains("missing launched Pod name")); + assert!( + store + .claim_for_ticket("20260608-000000-ready") + .unwrap() + .is_none() + ); + } + #[test] fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() { let temp = TempDir::new().unwrap(); @@ -7299,12 +8141,15 @@ mod tests { ) .unwrap(); - let warning = commit_intake_registry_update(IntakeRegistryUpdate::ClaimTicket { - registry_root: root, - ticket_id: "20260608-000001-existing".to_string(), - ticket_slug: Some("existing".to_string()), - pod_name: "second-intake".to_string(), - }) + let warning = commit_intake_registry_update( + IntakeRegistryUpdate::ClaimTicket { + registry_root: root, + ticket_id: "20260608-000001-existing".to_string(), + ticket_slug: Some("existing".to_string()), + pod_name: "second-intake".to_string(), + }, + None, + ) .expect("conflicting post-success claim should be reported"); assert!(warning.contains("could not be committed")); diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 40b2ebdb..07e1b63b 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -15,6 +15,8 @@ use crossterm::event::{ }; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{Command, execute}; +#[cfg(feature = "e2e-test")] +use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use protocol::{Method, PodStatus}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; @@ -75,6 +77,15 @@ pub(crate) async fn run_pod_name( socket_override: Option, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { + #[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 { let mut terminal = enter_fullscreen()?; run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?; @@ -248,6 +259,16 @@ pub(crate) async fn run_spawn( profile: Option, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { + #[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? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), @@ -388,6 +409,181 @@ fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender Result<(), Box> { + 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 = 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

{ Terminal(TerminalEventResult), Pod(Option

), diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index dc5b0690..11b85a16 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.0" edition.workspace = true license.workspace = true publish = false +autotests = false [features] default = [] @@ -19,3 +20,8 @@ tempfile.workspace = true name = "panel" path = "tests/panel.rs" required-features = ["e2e"] + +[[test]] +name = "rewind" +path = "tests/rewind.rs" +required-features = ["e2e"] diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 9157e586..b3dc4c35 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -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![ "HOME", "XDG_DATA_HOME", @@ -118,12 +118,19 @@ fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy { if include_hold_background_task { allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK"); } + if include_rewind_fixture { + allowlist.push("YOI_TUI_TEST_REWIND_FIXTURE"); + } env_policy( &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 { TestedYoiEnvPolicy { fixture_setup: fixture_setup_env_policy(), @@ -150,6 +157,9 @@ pub enum HarnessError { MouseCaptureNotEnabled { artifacts: PanelArtifacts, }, + FullDragMouseCaptureEnabled { + artifacts: PanelArtifacts, + }, Protocol(String), } @@ -184,6 +194,11 @@ impl std::fmt::Display for HarnessError { "terminal mouse capture was not observed before mouse input; artifacts at {}", 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}"), } } @@ -215,6 +230,8 @@ pub struct PanelHarnessConfig { pub fixture_root: PathBuf, pub terminal_size: (u16, u16), pub hold_background_task: Option, + pub rewind_fixture: bool, + pub command_args: Vec, pub artifacts_dir: PathBuf, } @@ -260,6 +277,7 @@ pub struct RowsRendered { pub enum KeyPress { CtrlC, CtrlD, + CtrlR, Enter, Esc, Text(String), @@ -299,11 +317,13 @@ impl PanelHarness { fs::write(&artifacts.events_jsonl, "")?; fs::write(&artifacts.input_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( &artifacts.run_json, serde_json::to_vec_pretty(&serde_json::json!({ "binary": config.binary, + "args": &config.command_args, "workspace": config.workspace, "home": config.home, "xdg_data_home": config.xdg_data_home, @@ -321,6 +341,7 @@ impl PanelHarness { "rows": config.terminal_size.1, }, "hold_background_task": config.hold_background_task, + "rewind_fixture": config.rewind_fixture, "tested_yoi_env_policy": &env_policy, }))?, )?; @@ -331,9 +352,7 @@ impl PanelHarness { let mut command = Command::new(&config.binary); command - .arg("panel") - .arg("--workspace") - .arg(&config.workspace) + .args(&config.command_args) .env_clear() .env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl) .env("YOI_POD_RUNTIME_COMMAND", &config.binary) @@ -349,6 +368,9 @@ impl PanelHarness { if let Some(task) = &config.hold_background_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 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 { + self.wait_for(event_name, timeout, |event| event.event == event_name) + } + + pub fn count_events(&mut self, event_name: &str) -> Result { + 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<()> { let start = Instant::now(); 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<()> { match key { KeyPress::CtrlC => self.write_input("Ctrl+C", b"\x03"), 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::Esc => self.write_input("Esc", b"\x1b"), KeyPress::Text(text) => self.write_input(&format!("text {text:?}"), text.as_bytes()), @@ -587,6 +691,13 @@ impl PanelHarness { .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<()> { if let Ok(output) = self.output.lock() { fs::write(&self.artifacts.output_log, &*output)?; @@ -744,6 +855,12 @@ impl FixtureWorkspace { fixture_root: self.root.clone(), terminal_size: (100, 32), 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(), } } @@ -758,6 +875,19 @@ impl FixtureWorkspace { 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 { 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") } +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 { let last_enable = last_subsequence_index(output, enable); let last_disable = last_subsequence_index(output, disable); @@ -1253,6 +1392,7 @@ mod tests { "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", "YOI_POD_RUNTIME_COMMAND", ] ); @@ -1266,6 +1406,7 @@ mod tests { "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", "TERM", "YOI_TUI_TEST_EVENTS", "YOI_POD_RUNTIME_COMMAND", diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 5ae4cf45..5b094758 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -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))?; 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, @@ -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 {}", 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)?; let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; diff --git a/tests/e2e/tests/rewind.rs b/tests/e2e/tests/rewind.rs new file mode 100644 index 00000000..386bde93 --- /dev/null +++ b/tests/e2e/tests/rewind.rs @@ -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(()) +}