diff --git a/docs/architecture.md b/docs/architecture.md index 73763adc..be98522e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,11 +40,11 @@ Writing standard: plain language, ISO 24495-1:2023 - **The central idea.** A runner-owned **commit ledger** records which plan item each commit belongs to. The linking engine uses the ledger, not commit messages, to put every changed line on a plan item's row. Anything the ledger cannot explain goes to the Unplanned or Ambiguous row. - **The central safety rule.** Everything that comes from outside is untrusted: issue text, agent output, the browser, and the repository's own files. Only the runner decides. Agents never see your credentials, your other files, or GitHub. - **Build state:** - - **In use:** plan and linking library, SQLite store, review screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. + - **In use:** plan and linking library, SQLite store, review screen, plan display/import screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock. - **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it. - **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet. - **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). No production request invokes the rebaser yet. - - **Not built:** screens for planning and the runner actions, production rebase admission, post-rebase attribution and approval refresh, head-bound `cmd:` checks, remote push/check refresh, review rounds, and the Planning, Queue and Learning screens. + - **Not built:** plan authoring and suggestion controls, screens for the runner actions, production rebase admission, post-rebase attribution and approval refresh, head-bound `cmd:` checks, remote push/check refresh, review rounds, and the Queue and Learning screens. ## Terms used @@ -164,7 +164,7 @@ Dependencies point downward only. `core/` imports nothing that does input or out |---|---|---|---| | Command line | `web/cli.ts` | Parses `--demo` or `--config`, takes the single-runner lock, starts the server, releases the lock on Ctrl+C. | In use | | HTTP server | `web/server.ts` | Binds `127.0.0.1` only. Checks the token, Host and Origin. Bounded JSON bodies. Routes `/api/review`, `/api/action`, `/api/questions`, `/api/merge`, `/api/issues`, `/api/settings`, plus `/api/runner` and `/api/plan/*` when a runner or planning provider is supplied. Ordered shutdown. | In use; `/api/plan/suggestions` and `/api/plan/drafts` run Claude through lane D for a review with a `github` block (#117, #124) | -| Browser screen | `web/public/*` | Review screen and Issues screen. Plain JavaScript with IBM Plex fonts served locally. Sends item and segment IDs and a state token, never fingerprints or ownership. | In use | +| Browser screen | `web/public/*` | Review, Issues and Plans screens. Plans displays the complete current revision and imports a complete next JSON or YAML revision through the planning API. Plain JavaScript with IBM Plex fonts served locally. Sends item and segment IDs and a state token, never fingerprints or ownership. | In use | | Review service | `runner/review.ts` | Builds the review view: reads history, links it to the plan, computes approval states and merge blockers. Applies review commands through the store. | In use | | Ask | `runner/questions.ts`, `runner/question-*.ts` | Answers a question about one plan item with a read-only Claude agent (Codex is refused in every phase, #93). Runs lane D's setup in a worker thread so the server stays responsive. Ask and planning share one read-only container runner (`runReadOnlyAgent`, #117) but keep separate workers, owners and leftovers. Labels its Docker objects with a per-database Ask owner and recovers only its own leftovers, so reviews sharing one Docker daemon do not block each other (#95). | In use | | Merge coordinator | `runner/merge.ts` | Runs the full merge gate twice, re-reads the local generation, then merges the exact reviewed head. Tracks merge-queue attempts. For a runner task it inspects and merges the task's own published pull request; a configured `github.pullRequest` that differs is refused (#121). | In use | @@ -180,7 +180,7 @@ Dependencies point downward only. `core/` imports nothing that does input or out | Linking engine | `core/linking.ts` | Replays each commit's line changes and assigns every final line to an owner from the ledger. Produces segments and the Unplanned and Ambiguous rows. | In use | | Approvals | `core/approvals.ts` | Approval fingerprints, dependency staleness, assignments, and accept-as-is choices. | In use | | Issue ranking | `core/issue-ranking.ts`, `github/issues.ts`, `web/issues.ts` | Deterministic, explainable issue score. Repository-scoped trust of issue authors. | In use | -| Planning library | `core/planning-author.ts`, `core/planning-suggestions.ts`, `prompts/plan-author.md` | Builds bounded planning prompts and validates plan drafts and suggestion cards. Does not run an agent itself; the planning agent above does. Recorded Claude output replays through it in tests (E4, #45). | In use through `/api/plan/*` (#117, #124); no screen yet | +| Planning library | `core/planning-author.ts`, `core/planning-suggestions.ts`, `prompts/plan-author.md` | Builds bounded planning prompts and validates plan drafts and suggestion cards. Does not run an agent itself; the planning agent above does. Recorded Claude output replays through it in tests (E4, #45). | In use through `/api/plan/*` (#117, #124); Plans displays and imports revisions, while authoring and suggestion controls remain future work | | Execution prompt and audit | `core/execution-prompt.ts`, `core/run-audit.ts`, `prompts/execute.md` | Builds the execute and fix prompt with untrusted text only in escaped data blocks. Audits the change manifest after a run: safety violations first (Git metadata, links that leave the repository, agent commits), then scope against the declared files. | In use with the opt-in `runner` block | | Pull request text | `core/pull-request-body.ts` | Title and description with the plan. Plan and agent text go in fenced blocks, and issue references and @-mentions are neutralized, so the text cannot close other issues or notify people. | In use with the opt-in `runner` block | | Git adapter | `git/history.ts`, `git/clone.ts` | Bounded, hardened history reads. Standalone task clones with no links back to your repository. Every Git command runs with an allowlisted environment. | In use | @@ -413,7 +413,7 @@ These are the decisions that shape the structure. The full list, with the reason | F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). Next in #22: F5 owns current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6 owns rewritten-head push, fresh required-check refresh, the already-fixed rerun, and exact-pair guarded-merge handoff. The integrated flow pushes a rewritten head before it recomputes review state and reruns every plan item's head-bound `cmd:` checks; fresh required GitHub checks follow. | | Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). | | Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). | -| G | Planning screen | Not started. Unblocked: E4 is done and the planning API runs Claude in production. | +| G | Planning screen | G1 built (#145, PR #148): display the complete current plan and import the next JSON or YAML revision. G2 authoring/suggestion cards, G3 revision-bound Apply, and G4 real-provider integration acceptance remain. | | H | Issue ranking and Issues screen | Done. H4b (#108, PR #139) records author-bound trust, guards runner and publishing boundaries, controls which comments reach prompts, records prompt comment evidence, and provides trust/untrust controls on the Issues screen. | | I, J | Queue and run windows; learning from feedback | Not started (after F) | @@ -424,7 +424,7 @@ These are the decisions that shape the structure. The full list, with the reason - The container's guarantees are those of Docker. The agent can always reach its own vendor account. - `cmd:` acceptance checks do not run yet, so any plan item with a `cmd:` check blocks merging. - One user, one machine, one runner per database. The lock refuses a second runner, and it refuses network filesystems, where OS locks are unreliable. -- The runner is opt-in: it needs a `runner` block and a `github` block in `review.json`, and it is always off in the demo. It runs through the API only; the screen has no start, resume, publish or planning controls yet. Publishing also needs `github.baseBranch`. +- The runner is opt-in: it needs a `runner` block and a `github` block in `review.json`, and it is always off in the demo. It runs through the API only; the screen has no start, resume, publish, plan-authoring, suggestion or Apply controls yet. Publishing also needs `github.baseBranch`. - codeboost is Claude-only. Codex is refused in every phase (#93, PR #106), because it can read files only through its shell, which every phase turns off. `agent-isolation.md` says when to revisit this. ## Test this document with a reader diff --git a/docs/implementation/planning-screen.md b/docs/implementation/planning-screen.md new file mode 100644 index 00000000..a88f066f --- /dev/null +++ b/docs/implementation/planning-screen.md @@ -0,0 +1,21 @@ +# Planning screen + +**Lane and step:** lane G, step G1. Tracking issue: #145. + +G1 adds a Plans screen over the planning contracts already owned by lanes E and F. It does not add another plan writer: the browser imports through `POST /api/plan/import`, then reloads `GET /api/review` so the current Store revision remains the only displayed authority. + +## Display contract + +The screen renders the complete current plan: issue, summary, revision, open questions, ordered items, dependencies, files (including both sides of a rename) and acceptance checks. Navigation uses `?view=plans`, announces the active destination with `aria-current`, and preserves the existing Review composer DOM while the Plans screen is open. An authoritative Review refresh reconciles a settled Plans revision or load-failure status with the response it applies, but only while no newer plan operation owns that status. A failed Review refresh still clears unavailable plan data, but likewise replaces the Plans status only while it retains ownership. + +## Import contract + +The person selects a `.json`, `.yaml` or `.yml` file. Its extension chooses the existing import format; the browser sends the file contents, current revision and a UUID action ID to F1's import endpoint. A retry after an ambiguous transport outcome retains the exact action ID and request by format and file contents, even when another file is definitely refused or a refresh has since observed the first request's commit. A definite refusal clears only that file's retry. A successful import is reported as committed before the browser reloads the current plan, so a reload failure is not described as an import failure. The focused submit control stays enabled with `aria-disabled` and an in-flight guard. If the person selects another file while the request runs, the older response does not clear that newer selection. Every full review response uses one shared generation: an import or merge advances it, so a Review or Plans refresh captured before that write cannot replace the committed state in the UI. Review actions and full Review, Plans and post-import reload responses invalidate merge polls that return afterward. Each captures the accepted-poll generation before its request and preserves a same-action, same-head queue observation accepted while it was pending, including submitting-to-queued, terminal, position, phase and observation-error updates. A terminal full response remains authoritative over either active or matching-terminal poll state so its verified terminal result, blockers and retry readiness are not discarded. A queued full response likewise wins over a stale submitting observation. Plans and post-import reloads do the same for completed or failed answers with the same note and answer-attempt IDs, including cancellation settlement, while keeping fresh blockers and stale-context metadata from the full response. Superseding work releases the stale operation's UI ownership immediately, while a failed plan operation resumes any current merge-queue polling it interrupted. Writes are kept single-flight across the Review and Plans screens. + +The Store validates schema, issue identity, revision, paths, dependencies and commands. G1 does not duplicate those rules in JavaScript. A rejected import leaves the rendered and durable revision unchanged. + +## Acceptance + +`test/browser/plans.spec.ts` covers JSON and YAML imports, full plan and rename rendering, navigation/reload, unsent Review draft preservation, duplicate activation and focus, a newer file selection surviving an older response, exact ambiguous retry before and after a refresh observes a lost-response commit, retry preservation across another file's refusal, committed-import/reload-failure reporting, post-import Review writes while a superseded read remains unsettled, immediate stale Plans UI release after an irreversible merge, both orderings of Review/Plans refreshes against terminal merge and question observations, terminal full-response authority over matching terminal and active polls, submitting-to-queued progress in both response orders, cancellation settlement, both response orderings between review actions and merge polls, queue-poll resumption after a rejected import, and an issue-mismatch refusal that leaves the Store at the original revision. + +G2 adds authoring and suggestion cards. G3 adds revision-bound Apply and request-time draft/attachment preservation. G4 completes real-provider/store integration and the remaining T18 browser/adapter gate. diff --git a/test/browser/plans.spec.ts b/test/browser/plans.spec.ts new file mode 100644 index 00000000..e9d9f76f --- /dev/null +++ b/test/browser/plans.spec.ts @@ -0,0 +1,1642 @@ +import { test, expect } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; +import { chmodSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDemo } from '../../scripts/demo.ts'; +import { startServer } from '../../web/server.ts'; +import type { MergeGateway, MergeQueueGateway } from '../../github/merge.ts'; +import { fixtureGit } from '../fixtures/git.ts'; + +let root: string, app: Awaited>; +test.beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'codeboost-plans-browser-')); + app = await startServer(createDemo(join(root, 'demo')), 0); +}); +test.afterEach(async () => { + await app.close(); + rmSync(root, { recursive: true, force: true }); +}); + +const nextPlan = { + schema_version: 1, + issue: 3, + revision: 2, + summary: 'Make retry limits visible to operators', + items: [ + { + id: 'P1', + title: 'Expose the retry ceiling', + intent: 'Keep the bounded delay and make its ceiling explicit.', + files: [{ path: 'retry.ts', kind: 'edit', renamed_from: null, change: 'Name the retry ceiling.' }], + acceptance: [{ type: 'check', text: 'The delay never exceeds five seconds.' }], + depends_on: [], + }, + { + id: 'P2', + title: 'Document the named limit', + intent: 'Explain the operator-visible retry ceiling.', + files: [{ path: 'RETRYING.md', kind: 'rename', renamed_from: 'README.md', change: 'Rename and update the retry guide.' }], + acceptance: [{ type: 'check', text: 'The documentation names the five-second limit.' }], + depends_on: ['P1'], + }, + ], + questions: ['Should operators be able to lower the ceiling?'], +} as const; + +const yaml = `schema_version: 1 +issue: 3 +revision: 2 +summary: Make retry limits visible to operators +items: + - id: P1 + title: Expose the retry ceiling + intent: Keep the bounded delay and make its ceiling explicit. + files: + - path: retry.ts + kind: edit + renamed_from: null + change: Name the retry ceiling. + acceptance: + - type: check + text: The delay never exceeds five seconds. + depends_on: [] + - id: P2 + title: Document the named limit + intent: Explain the operator-visible retry ceiling. + files: + - path: RETRYING.md + kind: rename + renamed_from: README.md + change: Rename and update the retry guide. + acceptance: + - type: check + text: The documentation names the five-second limit. + depends_on: [P1] +questions: + - Should operators be able to lower the ceiling? +`; + +for (const fixture of [ + { format: 'JSON', name: 'plan.json', mimeType: 'application/json', source: JSON.stringify(nextPlan) }, + { format: 'YAML', name: 'plan.yaml', mimeType: 'application/yaml', source: yaml }, +]) { + test(`displays the current plan and imports the next ${fixture.format} revision without losing a review draft`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(app.url); + await page.getByLabel('Question about this item').fill('Keep this unsent question.'); + + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await expect(page).toHaveURL(/\?view=plans$/); + await expect(page.getByRole('link', { name: 'Plans', exact: true })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByRole('heading', { name: 'Make retries predictable and document the behavior' })).toBeVisible(); + await expect(page.locator('#plans-revision')).toHaveText('r1'); + await expect(page.getByRole('article', { name: 'Bound exponential retries' })).toContainText('retry.ts'); + await expect(page.getByRole('article', { name: 'Document retry behavior' })).toContainText('Depends on P1'); + + await page.getByLabel('Plan file').setInputFiles({ name: fixture.name, mimeType: fixture.mimeType, buffer: Buffer.from(fixture.source) }); + await expect(page.locator('#plan-file-details')).toContainText(`${fixture.name} · ${fixture.format}`); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + await expect(page.locator('#plans-revision')).toHaveText('r2'); + await expect(page.getByRole('heading', { name: 'Make retry limits visible to operators' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Open questions' })).toBeVisible(); + await expect(page.getByText('Should operators be able to lower the ceiling?')).toBeVisible(); + const renamed = page.getByRole('article', { name: 'Document the named limit' }); + await expect(renamed).toContainText('Depends on P1'); + await expect(renamed.getByText('README.md')).toBeVisible(); + await expect(renamed.getByText('RETRYING.md')).toBeVisible(); + + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await expect(page.getByLabel('Question about this item')).toHaveValue('Keep this unsent question.'); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.reload(); + await expect(page).toHaveURL(/\?view=plans$/); + await expect(page.locator('#plans-revision')).toHaveText('r2'); + if (fixture.format === 'JSON') await page.screenshot({ path: 'test-results/plans-desktop.png', fullPage: true }); + expect(errors).toEqual([]); + }); +} + +test('shows a rejected import without replacing the current plan', async ({ page }) => { + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + const wrongIssue = JSON.stringify({ ...nextPlan, issue: 413 }); + await page.getByLabel('Plan file').setInputFiles({ name: 'wrong-issue.json', mimeType: 'application/json', buffer: Buffer.from(wrongIssue) }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Could not import the plan.' })).toContainText('selected issue'); + await expect(page.locator('#plans-revision')).toHaveText('r1'); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(1); +}); + +test('keeps focus and a newly selected file when an older import response returns', async ({ page }) => { + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + const file = page.getByLabel('Plan file'); + await file.setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + let requests = 0; + await page.route('**/api/plan/import', async route => { + requests++; + arrived(); + await held; + await route.continue(); + }); + const submit = page.locator('#plan-import'); + await submit.focus(); + await submit.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + await captured; + await expect(submit).toBeFocused(); + await expect(submit).toHaveAttribute('aria-disabled', 'true'); + await file.setInputFiles({ name: 'review-later.yaml', mimeType: 'application/yaml', buffer: Buffer.from(yaml) }); + release(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + await expect(page.locator('#plan-file-details')).toContainText('review-later.yaml · YAML'); + expect(requests).toBe(1); +}); + +test('reuses the exact import action after an ambiguous response', async ({ page }) => { + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); + const sent: unknown[] = []; + await page.route('**/api/plan/import', async route => { + sent.push(route.request().postDataJSON()); + if (sent.length === 1) await route.abort('failed'); + else await route.continue(); + }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Could not import the plan.' })).toBeVisible(); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + expect(sent).toHaveLength(2); + expect(sent[1]).toEqual(sent[0]); +}); + +test('replays an ambiguous committed import after refresh without creating another revision', async ({ page }) => { + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); + const sent: unknown[] = []; + await page.route('**/api/plan/import', async route => { + sent.push(route.request().postDataJSON()); + if (sent.length === 1) { + await route.fetch(); + await route.abort('failed'); + return; + } + await route.continue(); + }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Could not import the plan.' })).toBeVisible(); + await page.locator('#plans-refresh').click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + expect(sent).toHaveLength(2); + expect(sent[1]).toEqual(sent[0]); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(2); +}); + +test('does not let a replayed import reload overwrite a newer terminal merge observation', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); + const sent: unknown[] = []; + await page.route('**/api/plan/import', async route => { + sent.push(route.request().postDataJSON()); + if (sent.length === 1) { + await route.fetch(); + await route.abort('failed'); + return; + } + await route.continue(); + }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Could not import the plan.' })).toBeVisible(); + await page.locator('#plans-refresh').click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + + let view = app.service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) + view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Merge PR', exact: true })).toBeEnabled(); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); + await expect.poll(() => app.service.store.getMergeAttempt(config.identity)?.state).toBe('queued'); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + + let releaseReload!: () => void, reloadArrived!: () => void; + const heldReload = new Promise(resolve => { releaseReload = resolve; }); + const capturedReload = new Promise(resolve => { reloadArrived = resolve; }); + let queued!: Record; + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + queued = refreshed.merge.queue; + refreshed.repository = 'import-replay-refreshed'; + refreshed.merge.blockers.push({ code: 'check', message: 'Fresh replay blocker is pending.' }); + reloadArrived(); + await heldReload; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await capturedReload; + expect(sent).toHaveLength(2); + expect(sent[1]).toEqual(sent[0]); + expect(queued.actionId).toEqual(expect.any(String)); + + await page.route('**/api/merge', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ queue: { ...queued, state: 'merged', phase: null, position: null, + occurredAt: '2026-10-07T08:08:00Z', url: 'https://github.com/example/repo/pull/7', reason: null } }), + })); + const mergedPoll = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: /P1 Expose the retry ceiling/ }).click(); + await mergedPoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merged', exact: true })).toBeDisabled(); + await page.unroute('**/api/merge'); + + const staleReloadResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + releaseReload(); + await (await staleReloadResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#repository')).toHaveText('import-replay-refreshed'); + await expect(page.getByRole('button', { name: 'Merged', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('merged'); + await expect(page.locator('#dialog-body')).toContainText('Fresh replay blocker is pending.'); +}); + +test('preserves an unresolved import retry while another file is definitely refused', async ({ page }) => { + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + const file = page.getByLabel('Plan file'); + const source = JSON.stringify(nextPlan); + await file.setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(source) }); + const sent: unknown[] = []; + await page.route('**/api/plan/import', async route => { + sent.push(route.request().postDataJSON()); + if (sent.length === 1) { + await route.fetch(); + await route.abort('failed'); + return; + } + await route.continue(); + }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Could not import the plan.' })).toBeVisible(); + + const wrongIssue = JSON.stringify({ ...nextPlan, issue: 413 }); + await file.setInputFiles({ name: 'wrong-issue.json', mimeType: 'application/json', buffer: Buffer.from(wrongIssue) }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Could not import the plan.' })).toContainText('Stale plan revision.'); + + await page.locator('#plans-refresh').click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + await file.setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(source) }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + expect(sent).toHaveLength(3); + expect(sent[2]).toEqual(sent[0]); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(2); +}); + +test('reports a committed import separately when reloading the plan fails', async ({ page }) => { + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); + await page.route('**/api/review', route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Reload unavailable.' }) })); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 was imported' })).toContainText('could not reload'); + await expect(page.locator('#plans-revision')).toHaveText('r1'); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(2); +}); + +test('does not let an older Review refresh overwrite a committed import', async ({ page }) => { + await page.goto(app.url); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + let reads = 0; + await page.route('**/api/review', async route => { + reads++; + if (reads !== 1) { await route.continue(); return; } + const old = await route.fetch(); + arrived(); + await held; + await route.fulfill({ response: old }); + }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await captured; + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: 'Approve P1', exact: true }).click(); + await expect(page.getByText('1 of 2 approved')).toBeVisible(); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + const staleReviewResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + release(); + await (await staleReviewResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#reload')).toHaveText('Refresh'); + await expect(page.locator('#plans-revision')).toHaveText('r2'); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(2); +}); + +test('clears stale Plans data when a Review refresh fails after navigation', async ({ page }) => { + await page.goto(app.url); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + await page.route('**/api/review', async route => { + arrived(); + await held; + await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Review read failed.' }) }); + }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await captured; + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Make retries predictable and document the behavior' })).toBeVisible(); + release(); + await expect(page.locator('#reload')).toHaveText('Refresh'); + await expect(page.locator('#plans-summary')).toHaveText('Plan unavailable'); + await expect(page.locator('#plans-status')).toContainText('Could not read this branch’s history. Review read failed. Use Refresh to retry.'); +}); + +test('updates a settled Plans revision status when Review refresh observes a newer plan', async ({ page }) => { + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r1 is current.' })).toBeVisible(); + + const response = await fetch(new URL('/api/plan/import', app.url), { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-codeboost-token': app.token }, + body: JSON.stringify({ source: JSON.stringify(nextPlan), format: 'json', expectedRevision: 1, actionId: randomUUID() }), + }); + expect(response.status).toBe(200); + + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await expect(page.locator('#plans-revision')).toHaveText('r2'); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + await expect(page.locator('#plans-status')).not.toContainText('Revision r1 is current.'); +}); + +test('clears a Plans load-failure status when Review refresh recovers', async ({ page }) => { + await page.goto(app.url); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + await page.route('**/api/review', async route => { + arrived(); + await held; + await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Review read failed.' }) }); + }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await captured; + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + release(); + await expect(page.locator('#plans-status')).toContainText('Could not read this branch’s history. Review read failed.'); + + await page.unroute('**/api/review'); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r1 is current.' })).toBeVisible(); + await expect(page.locator('#plans-status')).not.toContainText('Review read failed.'); +}); + +test('does not let an older failed Review refresh replace newer Plans validation feedback', async ({ page }) => { + await page.goto(app.url); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + await page.route('**/api/review', async route => { + arrived(); + await held; + await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Older Review failure.' }) }); + }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await captured; + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.getByLabel('Plan file').setInputFiles({ name: 'plan.txt', mimeType: 'text/plain', buffer: Buffer.from('not a plan') }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.locator('#plans-status')).toContainText('Choose a JSON or YAML plan file.'); + + const staleReviewResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + release(); + await (await staleReviewResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#plans-summary')).toHaveText('Plan unavailable'); + await expect(page.locator('#plans-status')).toContainText('Choose a JSON or YAML plan file.'); + await expect(page.locator('#plans-status')).not.toContainText('Older Review failure.'); +}); + +test('does not let a question poll started during Plans refresh replace its completed answer', async ({ page }) => { + const config = app.service.config; + await app.close(); + app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); + await page.goto(app.url); + await page.getByLabel('Question about this item').fill('Will this answer finish?'); + await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); + await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); + const note = app.service.store.getReviewNotes(config.identity).find(value => value.text === 'Will this answer finish?')!; + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + const completed = refreshed.notes.find((value: { id: string }) => value.id === note.id); + completed.answer = { ...completed.answer, status: 'complete', text: 'The fresh completed answer.' }; + completed.answerActive = false; + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + let releasePoll!: () => void, pollArrived!: () => void; + const heldPoll = new Promise(resolve => { releasePoll = resolve; }); + const capturedPoll = new Promise(resolve => { pollArrived = resolve; }); + await page.route('**/api/questions', async route => { + const response = await route.fetch(); + pollArrived(); + await heldPoll; + await route.fulfill({ response }); + }); + await capturedPoll; + releaseRefresh(); + await expect(page.locator('#plans-status')).toContainText('Revision r1 is current.'); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await expect(page.getByText('The fresh completed answer.', { exact: true })).toBeVisible(); + const stalePollResponse = page.waitForResponse(response => response.url().endsWith('/api/questions')); + releasePoll(); + await stalePollResponse; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByText('The fresh completed answer.', { exact: true })).toBeVisible(); + await expect(page.getByText('Agent · Answering…', { exact: true })).toHaveCount(0); +}); + +test('does not let an older Plans refresh replace an answer completed while it was pending', async ({ page }) => { + const config = app.service.config; + await app.close(); + app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); + await page.goto(app.url); + await page.getByLabel('Question about this item').fill('Did this finish during refresh?'); + await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); + await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); + const note = app.service.store.getReviewNotes(config.identity).find(value => value.text === 'Did this finish during refresh?')!; + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + refreshArrived(); + await heldRefresh; + await route.fulfill({ response }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + const completedAnswer = { ...note.answer, status: 'complete', text: 'The poll completed this answer.' }; + const completedPoll = page.waitForResponse(response => response.url().endsWith('/api/questions')); + let polls = 0; + await page.route('**/api/questions', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: polls++ === 0 ? [{ id: note.id, answer: completedAnswer, answerActive: false }] : [] }), + })); + await completedPoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#notes')).toContainText('The poll completed this answer.'); + + releaseRefresh(); + await expect(page.locator('#plans-status')).toContainText('Revision r1 is current.'); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await expect(page.getByText('The poll completed this answer.', { exact: true })).toBeVisible(); + await expect(page.getByText('Agent · Answering…', { exact: true })).toHaveCount(0); +}); + +test('does not let an older Plans refresh replace an answer failure from the same attempt', async ({ page }) => { + const config = app.service.config; + await app.close(); + app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); + await page.goto(app.url); + await page.getByLabel('Question about this item').fill('Will this failure stay visible?'); + await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); + await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); + const note = app.service.store.getReviewNotes(config.identity).find(value => value.text === 'Will this failure stay visible?')!; + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + refreshArrived(); + await heldRefresh; + await route.fulfill({ response }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + const failedAnswer = { ...note.answer, status: 'failed', error: 'The agent failed during refresh.' }; + const failedPoll = page.waitForResponse(response => response.url().endsWith('/api/questions')); + let polls = 0; + await page.route('**/api/questions', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: polls++ === 0 ? [{ id: note.id, answer: failedAnswer, answerActive: false }] : [] }), + })); + await failedPoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#notes')).toContainText('The agent failed during refresh.'); + + releaseRefresh(); + await expect(page.locator('#plans-status')).toContainText('Revision r1 is current.'); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await expect(page.getByText('! The agent failed during refresh.', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry answer', exact: true })).toBeVisible(); + await expect(page.getByText('Agent · Answering…', { exact: true })).toHaveCount(0); +}); + +test('does not let an older Plans refresh restore cancellation activity after failure settles', async ({ page }) => { + const config = app.service.config; + await app.close(); + app = await startServer(config, 0, (_prompt, signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }))); + await page.goto(app.url); + await page.getByLabel('Question about this item').fill('Did cancellation settle during refresh?'); + await page.getByRole('button', { name: 'Ask agent', exact: true }).click(); + await expect(page.getByText('Agent · Answering…', { exact: true })).toBeVisible(); + const note = app.service.store.getReviewNotes(config.identity).find(value => value.text === 'Did cancellation settle during refresh?')!; + const failedAnswer = { ...note.answer, status: 'failed', error: 'The agent timed out.' }; + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + const failed = refreshed.notes.find((value: { id: string }) => value.id === note.id); + failed.answer = failedAnswer; + failed.answerActive = true; + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + let polls = 0; + const settledPoll = page.waitForResponse(response => response.url().endsWith('/api/questions')); + await page.route('**/api/questions', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: polls++ === 0 ? [{ id: note.id, answer: failedAnswer, answerActive: false }] : [] }), + })); + await settledPoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Retry answer', exact: true })).toBeVisible(); + + const staleReviewResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + releaseRefresh(); + await (await staleReviewResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Retry answer', exact: true })).toBeVisible(); + await expect(page.getByText('Agent · Finishing cancellation…', { exact: true })).toHaveCount(0); +}); + +test('does not let an older Plans refresh re-enable merge after it commits', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + const gateway: MergeGateway = { + inspect: async () => { + const snapshot = app.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: false, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + }; + app = await startServer(config, 0, undefined, gateway); + let view = app.service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) + view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Merge PR', exact: true })).toBeEnabled(); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + await page.route('**/api/review', async route => { + const old = await route.fetch(); + arrived(); + await held; + await route.fulfill({ response: old }); + }); + await page.locator('#plans-refresh').click(); + await captured; + await page.getByRole('link', { name: 'Review', exact: true }).click(); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); + await expect(page.locator('#banner')).toContainText('Merge submitted.'); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await expect(page.locator('#plans-refresh')).toHaveText('Refresh plan'); + await expect(page.locator('#plans-status')).toBeEmpty(); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + const staleReviewResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + release(); + await (await staleReviewResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#plans-refresh')).toHaveText('Refresh plan'); + await expect(page.locator('#merge')).toBeDisabled(); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await expect(page.locator('#plans-status')).toBeEmpty(); + expect(app.service.store.getMergeAttempt(config.identity)?.state).toBe('merged'); +}); + +test('does not let a merge poll started during a review action replace restored retry readiness', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let review = app.service.load(); + for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + for (const item of review.items) + review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); + app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); + + let actionPending = false; + let releasePoll!: () => void, pollArrived!: () => void; + const heldPoll = new Promise(resolve => { releasePoll = resolve; }); + const capturedPoll = new Promise(resolve => { pollArrived = resolve; }); + const queued = { kind: 'queue', state: 'queued', actionId: attempt.actionId, reviewedHead: review.snapshot.head, + url: 'https://github.com/example/repo/pull/7', reason: null, phase: 'QUEUED', position: 1, + occurredAt: '2026-10-08T08:00:00Z', retryable: false }; + await page.route('**/api/merge', async route => { + if (!actionPending) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: queued }) }); + return; + } + pollArrived(); + await heldPoll; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: queued }) }); + }); + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + app.service.store.finishMergeAttempt(config.identity, attempt.id, { state: 'removed', reason: 'Required check failed.' }); + const retryReview = await page.request.get(new URL('/api/review', app.url).toString(), { + headers: { 'x-codeboost-token': app.token }, + }); + const actionResponse = await retryReview.json(); + expect(actionResponse.merge).toMatchObject({ ready: true, action: 'retry' }); + + let releaseAction!: () => void, actionArrived!: () => void; + const heldAction = new Promise(resolve => { releaseAction = resolve; }); + const capturedAction = new Promise(resolve => { actionArrived = resolve; }); + await page.route('**/api/action', async route => { + actionPending = true; + actionArrived(); + await heldAction; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(actionResponse) }); + }); + + await page.getByRole('button', { name: 'Request change', exact: true }).click(); + await page.getByLabel('Change to request').fill('Keep retry readiness after this response.'); + await page.getByRole('button', { name: 'Save change request', exact: true }).click(); + await capturedAction; + await page.getByRole('button', { name: /P2 Document retry behavior/ }).click(); + await capturedPoll; + const actionFinished = page.waitForResponse(response => response.url().endsWith('/api/action')); + releaseAction(); + await (await actionFinished).finished(); + await expect(page.getByRole('button', { name: 'Retry merge', exact: true })).toBeEnabled(); + + const stalePollFinished = page.waitForResponse(response => response.url().endsWith('/api/merge')); + releasePoll(); + await (await stalePollFinished).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Retry merge', exact: true })).toBeEnabled(); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, state: 'removed' }); +}); + +test('lets a full refresh verify retry readiness after a matching terminal poll', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let review = app.service.load(); + for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + for (const item of review.items) + review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); + app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); + + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + app.service.store.finishMergeAttempt(config.identity, attempt.id, { state: 'removed', reason: 'Required check failed.' }); + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + expect(refreshed.merge).toMatchObject({ ready: true, action: 'retry', queue: { + actionId: attempt.actionId, reviewedHead: review.snapshot.head, state: 'removed', + } }); + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + const terminalQueue = { + kind: 'queue', state: 'removed', actionId: attempt.actionId, reviewedHead: review.snapshot.head, + url: 'https://github.com/example/repo/pull/7', reason: 'Required check failed.', phase: null, position: null, + occurredAt: '2026-10-08T08:05:00Z', retryable: true, + }; + await page.route('**/api/merge', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ queue: terminalQueue }), + })); + const terminalPoll = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: /P1 Bound exponential retries/ }).click(); + await terminalPoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#banner')).toContainText('Refresh to verify retry readiness.'); + await expect(page.getByRole('button', { name: /blocker/, exact: true })).toBeDisabled(); + await page.unroute('**/api/merge'); + + const refreshResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + releaseRefresh(); + await (await refreshResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Retry merge', exact: true })).toBeEnabled(); + await expect(page.locator('#banner')).not.toContainText('Refresh to verify retry readiness.'); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, state: 'removed' }); +}); + +test('lets a terminal full response replace an active poll for the same merge attempt', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let review = app.service.load(); + for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + for (const item of review.items) + review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); + app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); + + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + app.service.store.finishMergeAttempt(config.identity, attempt.id, { state: 'removed', reason: 'Required check failed.' }); + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + expect(refreshed.merge).toMatchObject({ ready: true, action: 'retry', queue: { + actionId: attempt.actionId, reviewedHead: review.snapshot.head, state: 'removed', + } }); + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + await page.route('**/api/merge', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ queue: { + kind: 'queue', state: 'queued', actionId: attempt.actionId, reviewedHead: review.snapshot.head, + url: 'https://github.com/example/repo/pull/7', reason: null, phase: 'QUEUED', position: 2, + occurredAt: '2026-10-08T08:05:00Z', retryable: false, + } }), + })); + const activePoll = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: /P1 Bound exponential retries/ }).click(); + await activePoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#banner')).toContainText('Merge queued at position 2.'); + + const refreshResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + releaseRefresh(); + await (await refreshResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Retry merge', exact: true })).toBeEnabled(); + await expect(page.locator('#banner')).not.toContainText('Merge queued at position 2.'); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, state: 'removed' }); +}); + +test('lets a queued full response replace a stale submitting poll for the same merge attempt', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 3, + enqueuedAt: '2026-10-08T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let review = app.service.load(); + for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + for (const item of review.items) + review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); + + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Submitting…', exact: true })).toBeDisabled(); + app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); + app.service.store.observeQueuedMerge(config.identity, attempt.id, { entryId: 'MQE_1', phase: 'QUEUED', position: 3 }); + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + expect(refreshed.merge.queue).toMatchObject({ + actionId: attempt.actionId, reviewedHead: review.snapshot.head, state: 'queued', phase: 'QUEUED', position: 3, + }); + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + let polls = 0, releaseLaterPolls!: () => void; + const heldLaterPolls = new Promise(resolve => { releaseLaterPolls = resolve; }); + await page.route('**/api/merge', async route => { + if (polls++ > 0) { + await heldLaterPolls; + await route.continue(); + return; + } + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: { + kind: 'queue', state: 'submitting', actionId: attempt.actionId, reviewedHead: review.snapshot.head, + url: null, reason: null, phase: null, position: null, occurredAt: '2026-10-08T08:05:00Z', retryable: false, + observationError: 'The older queue inspection failed.', + } }) }); + }); + const stalePoll = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: /P1 Bound exponential retries/ }).click(); + await stalePoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#banner')).toContainText('The older queue inspection failed.'); + + try { + const refreshResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + releaseRefresh(); + await (await refreshResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + await expect(page.locator('#banner')).not.toContainText('The older queue inspection failed.'); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('queued'); + await expect(page.locator('#dialog-body')).toContainText('position 3'); + await expect(page.locator('#dialog-body')).not.toContainText('The older queue inspection failed.'); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, state: 'queued', position: 3 }); + } finally { + releaseLaterPolls(); + } +}); + +test('does not let an older review action response replace a newer queued observation', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_3', phase: 'QUEUED', position: 2, + enqueuedAt: '2026-10-08T08:10:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let review = app.service.load(); + for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + for (const item of review.items) + review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); + app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); + app.service.store.observeQueuedMerge(config.identity, attempt.id, { entryId: 'MQE_3', phase: 'QUEUED', position: 3 }); + + let releaseAction!: () => void, actionArrived!: () => void; + const heldAction = new Promise(resolve => { releaseAction = resolve; }); + const capturedAction = new Promise(resolve => { actionArrived = resolve; }); + let actionPending = false; + await page.route('**/api/action', async route => { + const response = await route.fetch(); + const actionResponse = await response.json(); + expect(actionResponse.merge.queue).toMatchObject({ actionId: attempt.actionId, reviewedHead: review.snapshot.head, + state: 'queued', phase: 'QUEUED', position: 3 }); + actionPending = true; + actionArrived(); + await heldAction; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(actionResponse) }); + }); + let polls = 0, releaseLaterPolls!: () => void; + const heldLaterPolls = new Promise(resolve => { releaseLaterPolls = resolve; }); + await page.route('**/api/merge', async route => { + if (!actionPending) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: { + kind: 'queue', state: 'queued', actionId: attempt.actionId, reviewedHead: review.snapshot.head, + url: 'https://github.com/example/repo/pull/7', reason: null, phase: 'QUEUED', position: 3, + occurredAt: '2026-10-08T08:05:00Z', retryable: false, + } }) }); + return; + } + polls++; + if (polls > 1) await heldLaterPolls; + await route.continue(); + }); + + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Request change', exact: true }).click(); + await page.getByLabel('Change to request').fill('Preserve queue progress after this response.'); + await page.getByRole('button', { name: 'Save change request', exact: true }).click(); + await capturedAction; + try { + const queuedPoll = page.waitForResponse(async response => response.url().endsWith('/api/merge') && + (await response.json()).queue?.position === 2); + await page.getByRole('button', { name: /P2 Document retry behavior/ }).click(); + await (await queuedPoll).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('position 2'); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, state: 'queued', position: 2 }); + + const staleAction = page.waitForResponse(response => response.url().endsWith('/api/action')); + releaseAction(); + await (await staleAction).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: /P1 Bound exponential retries/ }).click(); + await expect(page.getByText('Preserve queue progress after this response.', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('position 2'); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, state: 'queued' }); + expect(app.service.store.getReviewNotes(config.identity).some(note => note.text === 'Preserve queue progress after this response.')).toBe(true); + } finally { + releaseLaterPolls(); + } +}); + +for (const mismatch of [ + { label: 'action ID', change: (queue: Record) => ({ ...queue, actionId: randomUUID() }) }, + { label: 'reviewed head', change: (queue: Record) => ({ ...queue, reviewedHead: 'f'.repeat(40) }) }, +]) { + test(`does not preserve a newer queue observation with a different ${mismatch.label}`, async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_identity', phase: 'QUEUED', position: 3, + enqueuedAt: '2026-10-08T08:15:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let review = app.service.load(); + for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + for (const item of review.items) + review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); + app.service.store.queueMergeAttempt(config.identity, attempt.id, 'https://github.com/example/repo/pull/7'); + app.service.store.observeQueuedMerge(config.identity, attempt.id, { entryId: 'MQE_identity', phase: 'QUEUED', position: 3 }); + const observed = mismatch.change({ + kind: 'queue', state: 'queued', actionId: attempt.actionId, reviewedHead: review.snapshot.head, + url: 'https://github.com/example/repo/pull/7', reason: null, phase: 'QUEUED', position: 2, + occurredAt: '2026-10-08T08:16:00Z', retryable: false, + }); + + let releaseAction!: () => void, actionArrived!: () => void; + const heldAction = new Promise(resolve => { releaseAction = resolve; }); + const capturedAction = new Promise(resolve => { actionArrived = resolve; }); + await page.route('**/api/action', async route => { + const response = await route.fetch(); + const actionResponse = await response.json(); + expect(actionResponse.merge.queue).toMatchObject({ actionId: attempt.actionId, reviewedHead: review.snapshot.head, + state: 'queued', position: 3 }); + actionArrived(); + await heldAction; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(actionResponse) }); + }); + + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Request change', exact: true }).click(); + const note = `Keep the action response for a different ${mismatch.label}.`; + await page.getByLabel('Change to request').fill(note); + await page.getByRole('button', { name: 'Save change request', exact: true }).click(); + await capturedAction; + + let polls = 0, releaseLaterPolls!: () => void; + const heldLaterPolls = new Promise(resolve => { releaseLaterPolls = resolve; }); + await page.route('**/api/merge', async route => { + polls++; + if (polls > 1) await heldLaterPolls; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: observed }) }); + }); + try { + const mismatchedPoll = page.waitForResponse(async response => response.url().endsWith('/api/merge') && + (await response.json()).queue?.position === 2); + await page.getByRole('button', { name: /P2 Document retry behavior/ }).click(); + await (await mismatchedPoll).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('position 2'); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + + const staleAction = page.waitForResponse(response => response.url().endsWith('/api/action')); + releaseAction(); + await (await staleAction).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await page.getByRole('button', { name: /P1 Bound exponential retries/ }).click(); + await expect(page.getByText(note, { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('position 3'); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, reviewedHead: review.snapshot.head, position: 3 }); + expect(app.service.store.getReviewNotes(config.identity).some(saved => saved.text === note)).toBe(true); + } finally { + releaseLaterPolls(); + } + }); +} + +test('does not let an older Plans refresh replace a confirmed queued merge with submitting', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_2', phase: 'QUEUED', position: 2, + enqueuedAt: '2026-10-08T08:05:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let review = app.service.load(); + for (const segment of review.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + review = app.service.act({ action: 'accept', key: segment.key, token: review.token }); + for (const item of review.items) + review = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: review.token }); + const attempt = app.service.store.beginMergeAttempt(config.identity, review.expected as never, review.snapshot.head, null, 'queue', randomUUID()); + + await page.goto(app.url); + await expect(page.getByRole('button', { name: 'Submitting…', exact: true })).toBeDisabled(); + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const stale = await response.json(); + expect(stale.merge.queue).toMatchObject({ actionId: attempt.actionId, reviewedHead: review.snapshot.head, state: 'submitting' }); + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(stale) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + let polls = 0, releaseLaterPolls!: () => void; + const heldLaterPolls = new Promise(resolve => { releaseLaterPolls = resolve; }); + await page.route('**/api/merge', async route => { + polls++; + if (polls > 1) await heldLaterPolls; + await route.continue(); + }); + try { + const queuedPoll = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: /P2 Document retry behavior/ }).click(); + await (await queuedPoll).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + + const staleRefresh = page.waitForResponse(response => response.url().endsWith('/api/review')); + releaseRefresh(); + await (await staleRefresh).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + expect(app.service.store.getMergeAttempt(config.identity)).toMatchObject({ id: attempt.id, state: 'queued' }); + } finally { + releaseLaterPolls(); + } +}); + +test('does not let merge polls started during Review or Plans refresh overwrite full responses', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let view = app.service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) + view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + + await page.goto(app.url); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + let releaseReviewRefresh!: () => void, reviewRefreshArrived!: () => void; + const heldReviewRefresh = new Promise(resolve => { releaseReviewRefresh = resolve; }); + const capturedReviewRefresh = new Promise(resolve => { reviewRefreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const current = await route.fetch(); + const refreshed = await current.json(); + refreshed.repository = 'retry-service-refreshed'; + reviewRefreshArrived(); + await heldReviewRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await capturedReviewRefresh; + let releaseReviewPoll!: () => void, reviewPollArrived!: () => void; + const heldReviewPoll = new Promise(resolve => { releaseReviewPoll = resolve; }); + const capturedReviewPoll = new Promise(resolve => { reviewPollArrived = resolve; }); + await page.route('**/api/merge', async route => { + reviewPollArrived(); + await heldReviewPoll; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: { + state: 'removed', reviewedHead: view.snapshot.head, url: null, reason: 'Old Review refresh poll result.', phase: null, + position: null, occurredAt: '2026-10-07T08:04:00Z', retryable: true, + } }) }); + }); + await page.getByRole('button', { name: /P2 Document retry behavior/ }).click(); + await capturedReviewPoll; + releaseReviewRefresh(); + await expect(page.locator('#repository')).toHaveText('retry-service-refreshed'); + const staleReviewPollResponse = page.waitForResponse(response => response.url().endsWith('/api/merge')); + releaseReviewPoll(); + await staleReviewPollResponse; + await page.waitForTimeout(100); + await expect(page.locator('#banner')).not.toContainText('Old Review refresh poll result.'); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + await page.unroute('**/api/review'); + await page.unroute('**/api/merge'); + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + await page.route('**/api/review', async route => { + const current = await route.fetch(); + refreshArrived(); + await heldRefresh; + await route.fulfill({ response: current }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + + let releasePoll!: () => void, pollArrived!: () => void; + const heldPoll = new Promise(resolve => { releasePoll = resolve; }); + const capturedPoll = new Promise(resolve => { pollArrived = resolve; }); + await page.route('**/api/merge', async route => { + pollArrived(); + await heldPoll; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: { + state: 'removed', reviewedHead: view.snapshot.head, url: null, reason: 'Old refresh poll result.', phase: null, + position: null, occurredAt: '2026-10-07T08:05:00Z', retryable: true, + } }) }); + }); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: /P2 Document retry behavior/ }).click(); + await capturedPoll; + releaseRefresh(); + await expect(page.locator('#plans-status')).toContainText('Revision r1 is current.'); + const stalePollResponse = page.waitForResponse(response => response.url().endsWith('/api/merge')); + releasePoll(); + await stalePollResponse; + await page.waitForTimeout(100); + await expect(page.locator('#banner')).not.toContainText('Old refresh poll result.'); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); +}); + +test('does not let an older Plans refresh overwrite a newer terminal merge observation', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let view = app.service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) + view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + + await page.goto(app.url); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + let queued!: Record; + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + queued = refreshed.merge.queue; + refreshed.repository = 'poll-first-refreshed'; + refreshed.merge.blockers.push({ code: 'check', message: 'Fresh required check is pending.' }); + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await capturedRefresh; + expect(queued.actionId).toEqual(expect.any(String)); + + await page.route('**/api/merge', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ queue: { ...queued, state: 'merged', phase: null, position: null, + occurredAt: '2026-10-07T08:06:00Z', url: 'https://github.com/example/repo/pull/7', reason: null } }), + })); + const mergedPoll = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: /P1 Bound exponential retries/ }).click(); + await mergedPoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merged', exact: true })).toBeDisabled(); + await page.unroute('**/api/merge'); + + releaseRefresh(); + await expect(page.locator('#repository')).toHaveText('poll-first-refreshed'); + await page.waitForTimeout(100); + await expect(page.getByRole('button', { name: 'Merged', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('merged'); + await expect(page.locator('#dialog-body')).toContainText('GitHub confirmed that the reviewed head was merged.'); + await expect(page.locator('#dialog-body')).toContainText('Fresh required check is pending.'); +}); + +test('does not let an older Review refresh overwrite a newer terminal merge observation', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let view = app.service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) + view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + + await page.goto(app.url); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + + let releaseRefresh!: () => void, refreshArrived!: () => void; + const heldRefresh = new Promise(resolve => { releaseRefresh = resolve; }); + const capturedRefresh = new Promise(resolve => { refreshArrived = resolve; }); + let queued!: Record; + await page.route('**/api/review', async route => { + const response = await route.fetch(); + const refreshed = await response.json(); + queued = refreshed.merge.queue; + refreshed.repository = 'review-poll-first-refreshed'; + refreshed.merge.blockers.push({ code: 'check', message: 'Fresh Review blocker is pending.' }); + refreshArrived(); + await heldRefresh; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(refreshed) }); + }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await capturedRefresh; + expect(queued.actionId).toEqual(expect.any(String)); + + await page.route('**/api/merge', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ queue: { ...queued, state: 'merged', phase: null, position: null, + occurredAt: '2026-10-07T08:07:00Z', url: 'https://github.com/example/repo/pull/7', reason: null } }), + })); + const mergedPoll = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('button', { name: /P1 Bound exponential retries/ }).click(); + await mergedPoll; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('button', { name: 'Merged', exact: true })).toBeDisabled(); + await page.unroute('**/api/merge'); + + const staleReviewResponse = page.waitForResponse(response => response.url().endsWith('/api/review')); + releaseRefresh(); + await (await staleReviewResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.locator('#repository')).toHaveText('review-poll-first-refreshed'); + await expect(page.getByRole('button', { name: 'Merged', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'Merge status', exact: true }).click(); + await expect(page.locator('#dialog-body')).toContainText('merged'); + await expect(page.locator('#dialog-body')).toContainText('Fresh Review blocker is pending.'); +}); + +test('keeps merge-queue polling current across Plans refresh and import outcomes', async ({ page }) => { + test.slow(); + const config = { ...app.service.config, demo: false }; + await app.close(); + chmodSync(join(config.repository, 'run.sh'), 0o644); + fixtureGit(config.repository, 'commit', '-am', 'Restore declared scope'); + let appRef: typeof app; + const gateway: MergeGateway & MergeQueueGateway = { + inspect: async () => { + const snapshot = appRef.service.load().snapshot; + return { base: snapshot.base, head: snapshot.head, pullRequestState: 'OPEN', mergeable: 'MERGEABLE', rulesKnown: true, + atomicBaseGuard: true, mergeQueue: true, requiredChecks: [], alreadyFixed: 'clear', pullRequest: 7, draft: false }; + }, + queueWatermark: async () => null, + merge: async () => ({ url: 'https://github.com/example/repo/pull/7' }), + inspectQueue: async head => ({ state: 'queued', reviewedHead: head, entryId: 'MQE_1', phase: 'QUEUED', position: 1, + enqueuedAt: '2026-10-07T08:00:00Z', queueHead: head }), + }; + app = appRef = await startServer(config, 0, undefined, gateway); + let view = app.service.load(); + for (const segment of view.segments.filter(value => value.row === 'Unplanned' || value.row === 'Ambiguous')) + view = app.service.act({ action: 'accept', key: segment.key, token: view.token }); + for (const item of view.items) + view = app.service.act({ action: 'approve', item: item.id, confirmNoChange: item.count === 0, token: view.token }); + + let release!: () => void, releaseNew!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const heldNew = new Promise(resolve => { releaseNew = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + let polls = 0; + await page.route('**/api/merge', async route => { + polls++; + if (polls === 1) { + arrived(); + await held; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: { + state: 'removed', reviewedHead: view.snapshot.head, url: null, reason: 'Old poll result.', phase: null, + position: null, occurredAt: '2026-10-07T08:05:00Z', retryable: true, + } }) }); + return; + } + await heldNew; + await route.continue(); + }); + await page.goto(app.url); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Merge PR', exact: true }).click(); + await captured; + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + await page.locator('#plans-refresh').click(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r1 is current.' })).toBeVisible(); + const staleResponse = page.waitForResponse(response => response.url().endsWith('/api/merge')); + release(); + try { + await staleResponse; + await page.waitForTimeout(100); + await page.getByRole('link', { name: 'Review', exact: true }).click(); + await expect(page.locator('#banner')).not.toContainText('Old poll result.'); + await expect(page.getByRole('button', { name: 'Merge queued', exact: true })).toBeDisabled(); + } finally { + releaseNew(); + } + const pollsBeforeRefusal = polls; + const resumedPollResponse = page.waitForResponse(response => response.url().endsWith('/api/merge')); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); + const wrongIssue = JSON.stringify({ ...nextPlan, issue: 413 }); + await page.getByLabel('Plan file').setInputFiles({ name: 'wrong-issue.json', mimeType: 'application/json', buffer: Buffer.from(wrongIssue) }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Could not import the plan.' })).toBeVisible(); + await expect.poll(() => polls, { timeout: 10000 }).toBeGreaterThan(pollsBeforeRefusal); + await resumedPollResponse; + + await page.unroute('**/api/merge'); + const authoritative = await (await fetch(new URL('/api/review', app.url), { headers: { 'x-codeboost-token': app.token } })).json(); + authoritative.plan = nextPlan; + authoritative.merge = { ...authoritative.merge, ready: false, action: null, queue: null, + blockers: [{ code: 'plan-changed', message: 'The plan changed.' }] }; + let releaseImportResponse!: () => void, importRequestArrived!: () => void; + const heldImportResponse = new Promise(resolve => { releaseImportResponse = resolve; }); + const capturedImportRequest = new Promise(resolve => { importRequestArrived = resolve; }); + await page.route('**/api/plan/import', async route => { + importRequestArrived(); + await heldImportResponse; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ result: { revision: 2 } }) }); + }); + let releaseReload!: () => void, reloadArrived!: () => void; + const heldReload = new Promise(resolve => { releaseReload = resolve; }); + const capturedReload = new Promise(resolve => { reloadArrived = resolve; }); + await page.route('**/api/review', async route => { + reloadArrived(); + await heldReload; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(authoritative) }); + }); + let releaseImportPoll!: () => void, importPollArrived!: () => void; + const heldImportPoll = new Promise(resolve => { releaseImportPoll = resolve; }); + const capturedImportPoll = new Promise(resolve => { importPollArrived = resolve; }); + await page.getByLabel('Plan file').setInputFiles({ name: 'plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(nextPlan)) }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await capturedImportRequest; + await page.route('**/api/merge', async route => { + importPollArrived(); + await heldImportPoll; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ queue: { + state: 'removed', reviewedHead: view.snapshot.head, url: null, reason: 'Old import poll result.', phase: null, + position: null, occurredAt: '2026-10-07T08:10:00Z', retryable: true, + } }) }); + }); + releaseImportResponse(); + await capturedReload; + await capturedImportPoll; + releaseReload(); + await expect(page.getByRole('status').filter({ hasText: 'Revision r2 is current.' })).toBeVisible(); + const staleImportResponse = page.waitForResponse(response => response.url().endsWith('/api/merge')); + releaseImportPoll(); + await staleImportResponse; + await page.waitForTimeout(100); + await expect(page.locator('#plans-revision')).toHaveText('r2'); + await expect(page.locator('#banner')).not.toContainText('Old import poll result.'); +}); diff --git a/web/public/app.js b/web/public/app.js index 0c499655..60f6c423 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -19,10 +19,13 @@ let data, mode = "question", // The reviewer's explicit view choice, held only for the item and server-named stale state (staleKey) it was made on; otherwise a stale item opens the comparison. sinceChoice = null, - busy = false; + busy = false, + busyKind = null, + busyGeneration = 0; let reviewGeneration = 0; let view = "review"; let mergeGeneration = 0, + mergeObservationGeneration = 0, mergePollTimer = null, mergePollState = null, mergePollDelay = 2000; @@ -41,6 +44,17 @@ const statusClass = (text) => : text.startsWith("!") ? "warn" : "neutral"; +function beginBusy(kind) { + busy = true; + busyKind = kind; + return ++busyGeneration; +} +function endBusy(owner) { + if (owner !== busyGeneration) return; + busy = false; + busyKind = null; + renderAttachment(); +} async function api(path, body) { const response = await fetch(path, { method: body ? "POST" : "GET", @@ -57,7 +71,7 @@ async function api(path, body) { function rememberDraft() { if (selected) drafts.set(`${selected}:${mode}`, $("message").value); } -function showFailure(message) { +function showFailure(message, planStatusOwner) { mergeGeneration++; if (mergePollTimer) clearTimeout(mergePollTimer); mergePollTimer = null; @@ -65,6 +79,11 @@ function showFailure(message) { mergePollDelay = 2000; data = null; snippetSelection = null; + renderPlans(); + const planStatusClass = view === "plans" ? "bad" : "neutral"; + const planStatusMessage = view === "plans" ? `✕ ${message}` : ""; + if (planStatusOwner === undefined) claimPlanStatus(planStatusClass, planStatusMessage); + else setPlanStatus(planStatusOwner, planStatusClass, planStatusMessage); $("selection-actions").hidden = true; $("attachment").hidden = true; $("banner").textContent = message; @@ -91,10 +110,15 @@ function showFailure(message) { '

Review could not load

Refresh after resolving the error above.

'; } async function refresh() { - if (busy) return; - busy = true; - reviewGeneration++; + if (busy || planImportPending) return; + supersedePlanRefresh(); + const planStatusOwner = planStatusGeneration; + const busyOwner = beginBusy("read"); + const generation = ++reviewGeneration; + const mergeObservationOwner = mergeObservationGeneration; mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; mergePollState = null; mergePollDelay = 2000; renderAttachment(); @@ -102,19 +126,29 @@ async function refresh() { try { rememberDraft(); const updated = await api("/api/review"); + if (generation !== reviewGeneration) return; + mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; + mergePollState = null; + mergePollDelay = 2000; rememberDraft(); - data = updated; + const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); snippetSelection = null; selected ??= data.items[0]?.id || "Unplanned"; render(); + if (newerQueue) showMergeQueueStatus(newerQueue); + setPlanStatus(planStatusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); } catch (error) { + if (generation !== reviewGeneration) return; rememberDraft(); showFailure( `Could not read this branch’s history. ${error.message} Use Refresh to retry.`, + planStatusOwner, ); } finally { - busy = false; - renderAttachment(); + endBusy(busyOwner); } } function renderMerge() { @@ -163,19 +197,10 @@ async function pollMergeQueue(generation) { try { const update = await api("/api/merge"); if (generation !== mergeGeneration || !data?.merge?.available) return; - data = { ...data, merge: { ...data.merge, queue: update.queue } }; const queue = update.queue; - if (queue?.state === "merged") { - data.merge = { ...data.merge, ready: false, action: null, blockers: [{ code: "queue-merged", message: "GitHub confirmed that the reviewed head was merged." }] }; - $("banner").textContent = "GitHub confirmed the reviewed head was merged."; - } else if (queue?.state === "removed" || queue?.state === "failed") { - data.merge = { ...data.merge, ready: false, action: null, blockers: [{ code: "queue-refresh", message: `${queue.reason} Refresh to verify retry readiness.` }] }; - $("banner").textContent = `${queue.state === "removed" ? "Removed from merge queue" : "Merge queue failed"}. ${queue.reason} Refresh to verify retry readiness.`; - } else if (queue?.observationError) { - $("banner").textContent = `${queue.state === "submitting" ? "Merge submission status is unknown" : "Merge remains queued"}. ${queue.observationError}`; - } else if (queue?.state === "queued") { - $("banner").textContent = `Merge queued${queue.position === null ? "" : ` at position ${queue.position}`}. Waiting for GitHub.`; - } + mergeObservationGeneration++; + data = withMergeQueue(data, queue); + showMergeQueueStatus(queue); if (queue?.state === mergePollState) backOffMergePoll(); renderMerge(); } catch (error) { @@ -185,11 +210,37 @@ async function pollMergeQueue(generation) { scheduleMergePoll(); } } +const mergeLifecycleBlockers = new Set(["merge-submitted", "queue-active", "queue-head", "queue-merged", "queue-refresh", "stale-merge"]); +function withMergeQueue(view, queue) { + const blockers = view.merge.blockers.filter(blocker => !mergeLifecycleBlockers.has(blocker.code)); + if (queue?.state === "merged") + blockers.unshift({ code: "queue-merged", message: "GitHub confirmed that the reviewed head was merged." }); + else if (queue?.state === "removed" || queue?.state === "failed") + blockers.unshift({ code: "queue-refresh", message: `${queue.reason} Refresh to verify retry readiness.` }); + else + blockers.unshift(...view.merge.blockers.filter(blocker => blocker.code === "queue-active")); + return { ...view, merge: { ...view.merge, ready: false, action: null, queue, blockers } }; +} +function showMergeQueueStatus(queue) { + if (queue?.state === "merged") { + $("banner").textContent = "GitHub confirmed the reviewed head was merged."; + } else if (queue?.state === "removed" || queue?.state === "failed") { + $("banner").textContent = `${queue.state === "removed" ? "Removed from merge queue" : "Merge queue failed"}. ${queue.reason} Refresh to verify retry readiness.`; + } else if (queue?.observationError) { + $("banner").textContent = `${queue.state === "submitting" ? "Merge submission status is unknown" : "Merge remains queued"}. ${queue.observationError}`; + } else if (queue?.state === "queued") { + $("banner").textContent = `Merge queued${queue.position === null ? "" : ` at position ${queue.position}`}. Waiting for GitHub.`; + } +} async function act(command) { - if (busy || !data) return false; - busy = true; - reviewGeneration++; + if (busy || planImportPending || !data) return false; + supersedePlanRefresh(); + const busyOwner = beginBusy("write"); + const generation = ++reviewGeneration; + const mergeObservationOwner = mergeObservationGeneration; mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; mergePollState = null; mergePollDelay = 2000; renderAttachment(); @@ -197,17 +248,25 @@ async function act(command) { rememberDraft(); // One action ID per user action: the server replays it exactly and records feedback with it. const updated = await api("/api/action", { ...command, token: data.token, actionId: crypto.randomUUID() }); + if (generation !== reviewGeneration) return true; + mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; + mergePollState = null; + mergePollDelay = 2000; rememberDraft(); - data = updated; + const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + data = newerQueue ? withMergeQueue(updated, newerQueue) : updated; render(); + if (newerQueue) showMergeQueueStatus(newerQueue); return true; } catch (error) { + if (generation !== reviewGeneration) return false; rememberDraft(); showFailure(`${error.message} Refresh to review the latest state.`); return false; } finally { - busy = false; - renderAttachment(); + endBusy(busyOwner); } } const showSince = (item) => @@ -233,6 +292,7 @@ function render() { return render(); } $("repository").textContent = data.repository; + renderPlans(); $("issue").textContent = `#${data.plan.issue} ${data.plan.summary} · r${data.plan.revision}`; $("progress").textContent = @@ -468,8 +528,10 @@ $("merge-details").onclick = () => { ); }; $("merge").onclick = async () => { - if (busy || !data?.merge?.ready || !window.confirm(data.merge.action === "retry" ? "Retry merging this exact reviewed head?" : "Merge this reviewed pull request?")) return; - busy = true; + if (busy || planImportPending || !data?.merge?.ready || !window.confirm(data.merge.action === "retry" ? "Retry merging this exact reviewed head?" : "Merge this reviewed pull request?")) return; + supersedePlanRefresh(); + const busyOwner = beginBusy("write"); + reviewGeneration++; mergeGeneration++; mergePollState = null; mergePollDelay = 2000; @@ -494,7 +556,7 @@ $("merge").onclick = async () => { render(); $("banner").textContent = `Merge blocked. ${error.message}`; } finally { - busy = false; + endBusy(busyOwner); } }; $("review-link").onclick = (event) => { @@ -746,6 +808,34 @@ function renderNotes({ follow = false } = {}) { document.querySelectorAll("[data-retry-question]").forEach(button=>button.onclick=()=>act({action:"retry-question",id:button.dataset.retryQuestion})); } let pollingQuestions=false; +function preserveSettledQuestionAnswers(updated) { + if (!data) return updated; + const current = new Map(data.notes.map(note => [note.id, note])); + return { ...updated, notes: updated.notes.map(note => { + const newer = current.get(note.id); + const sameAttempt = newer?.answer?.attempt !== undefined && newer.answer.attempt === note.answer?.attempt; + const settledFromPending = note.answer?.status === "pending"; + const cancellationSettled = note.answerActive && newer?.answerActive === false && + newer.answer?.status === note.answer?.status; + return sameAttempt && ["complete", "failed"].includes(newer.answer.status) && + (settledFromPending || cancellationSettled) + ? { ...note, answer: newer.answer, answerActive: newer.answerActive } + : note; + }) }; +} +function newerMergeQueue(updated, observationOwner) { + const newer = data?.merge?.queue, older = updated.merge?.queue; + const fullResponseIsTerminal = ["merged", "removed", "failed"].includes(older?.state); + const pollRegressesQueued = older?.state === "queued" && newer?.state === "submitting"; + return observationOwner !== mergeObservationGeneration && + newer?.actionId && + newer.actionId === older?.actionId && + newer.reviewedHead === older.reviewedHead && + !fullResponseIsTerminal && + !pollRegressesQueued + ? newer + : null; +} setInterval(async()=>{ if(pollingQuestions || busy || !data || !data.notes.some(n=>n.answer?.status==="pending" || n.answerActive)) return; pollingQuestions=true; @@ -810,17 +900,168 @@ let issuesGeneration = 0, issuesLoading = false, issuesRefreshError = null, issueErrorOrder = 0; +let planImportPending = false, + planImportRetries = new Map(), + planRefreshPending = false, + planOperationGeneration = 0, + planStatusGeneration = 0; +function claimPlanStatus(className, text) { + const owner = ++planStatusGeneration; + $("plans-status").className = className; + $("plans-status").textContent = text; + return owner; +} +function setPlanStatus(owner, className, text) { + if (owner !== planStatusGeneration) return; + $("plans-status").className = className; + $("plans-status").textContent = text; +} +function supersedePlanRefresh() { + if (!planRefreshPending) return; + planOperationGeneration++; + planRefreshPending = false; + $("plans-refresh").removeAttribute("aria-disabled"); + $("plans-refresh").textContent = "Refresh plan"; + claimPlanStatus("neutral", ""); +} +function renderPlans() { + if (!data?.plan) { + $("plans-summary").textContent = "Plan unavailable"; + $("plans-revision").textContent = ""; + $("plans-items").innerHTML = ""; + $("plans-questions").innerHTML = ""; + return; + } + const plan = data.plan; + $("plans-identity").textContent = `#${plan.issue}`; + $("plans-summary").textContent = plan.summary; + $("plans-revision").textContent = `r${plan.revision}`; + $("plans-questions").innerHTML = plan.questions.length + ? `

Open questions

    ${plan.questions.map((question) => `
  • ${esc(question)}
  • `).join("")}
` + : ""; + $("plans-items").innerHTML = plan.items.map((item) => ` +
+
${esc(item.id)}

${esc(item.title)}

+

${esc(item.intent)}

+ ${item.depends_on.length ? `

Depends on ${item.depends_on.map((dependency) => `${esc(dependency)}`).join(" ")}

` : ""} +
+

Files

    ${item.files.map((file) => `
  • ${file.kind === "rename" ? `${esc(file.renamed_from)}` : ""}${esc(file.path)}${esc(file.kind)} · ${esc(file.change)}
  • `).join("")}
+

Acceptance

    ${item.acceptance.map((check) => `
  • ${esc(check.type)}${esc(check.text)}
  • `).join("")}
+
+
`).join(""); +} +function planFileFormat(name) { + return /\.ya?ml$/i.test(name) ? "yaml" : /\.json$/i.test(name) ? "json" : null; +} +function renderPlanFile() { + const file = $("plan-file").files[0]; + const format = file && planFileFormat(file.name); + $("plan-file-details").textContent = file + ? `${file.name} · ${format ? format.toUpperCase() : "Choose a .json, .yaml, or .yml file"}` + : "No file selected"; +} +async function importPlan(event) { + event.preventDefault(); + if (planImportPending || !data?.plan) return; + if (busy && busyKind !== "read") { + claimPlanStatus("warn", "! Wait for the current review action before importing a plan."); + return; + } + const file = $("plan-file").files[0], format = file && planFileFormat(file.name); + if (!file || !format) { + claimPlanStatus("bad", "✕ Choose a JSON or YAML plan file."); + return; + } + supersedePlanRefresh(); + planOperationGeneration++; + const sharedGeneration = ++reviewGeneration; + mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; + mergePollState = null; + mergePollDelay = 2000; + const busyOwner = beginBusy("write"); + planImportPending = true; + $("plan-import").setAttribute("aria-disabled", "true"); + $("plan-import").textContent = "Importing…"; + const statusOwner = claimPlanStatus("neutral", "Reading the plan file…"); + let source; + try { + source = await file.text(); + } catch (error) { + planImportPending = false; + endBusy(busyOwner); + $("plan-import").removeAttribute("aria-disabled"); + $("plan-import").textContent = "Import next revision"; + setPlanStatus(statusOwner, "bad", `✕ Could not read the plan file. ${error.message}`); + renderMerge(); + return; + } + const expectedRevision = data.plan.revision; + const retryKey = JSON.stringify([format, source]); + const request = planImportRetries.get(retryKey) ?? { source, format, expectedRevision, actionId: crypto.randomUUID() }; + planImportRetries.set(retryKey, request); + setPlanStatus(statusOwner, "neutral", "Importing the next revision…"); + try { + const response = await api("/api/plan/import", request); + const revision = Number.isSafeInteger(response.result?.revision) ? response.result.revision : null; + planImportRetries.delete(retryKey); + if (data?.merge?.available) data = { ...data, merge: { ...data.merge, ready: false, action: null, + blockers: [{ code: "plan-changed", message: "The plan changed. Refresh before merging." }] } }; + renderMerge(); + setPlanStatus(statusOwner, revision === null ? "warn" : "good", revision === null + ? "! The import returned success without a revision. Reloading the current plan…" + : `✓ Imported revision r${revision}. Reloading…`); + try { + rememberDraft(); + const mergeObservationOwner = mergeObservationGeneration; + const updated = await api("/api/review"); + if (sharedGeneration !== reviewGeneration) return; + mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; + mergePollState = null; + mergePollDelay = 2000; + rememberDraft(); + const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + render(); + if (newerQueue) showMergeQueueStatus(newerQueue); + if ($("plan-file").files[0] === file) $("plan-file").value = ""; + renderPlanFile(); + setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); + } catch (error) { + setPlanStatus(statusOwner, "warn", `! ${revision === null ? "The import returned success" : `Revision r${revision} was imported`}, but the current plan could not reload. ${error.message}`); + renderMerge(); + } + } catch (error) { + const ambiguous = !Number.isSafeInteger(error?.status) || error.status === 503 || error.outcomeUnknown === true; + if (!ambiguous) planImportRetries.delete(retryKey); + if (ambiguous && data?.merge?.available) data = { ...data, merge: { ...data.merge, ready: false, action: null, + blockers: [{ code: "plan-import-unknown", message: "The plan import outcome is unknown. Refresh before merging." }] } }; + setPlanStatus(statusOwner, "bad", `✕ Could not import the plan. ${error.message}`); + renderMerge(); + } finally { + planImportPending = false; + endBusy(busyOwner); + $("plan-import").removeAttribute("aria-disabled"); + $("plan-import").textContent = "Import next revision"; + } +} function showView(next) { view = next; $("review-view").hidden = next !== "review"; $("issues-view").hidden = next !== "issues"; - for (const [id, name] of [["review-link", "review"], ["issues-link", "issues"]]) { + $("plans-view").hidden = next !== "plans"; + for (const [id, name] of [["review-link", "review"], ["issues-link", "issues"], ["plans-link", "plans"]]) { if (name === next) $(id).setAttribute("aria-current", "page"); else $(id).removeAttribute("aria-current"); } - document.title = `${next === "issues" ? "Issues" : "Review"} · codeboost`; - history.replaceState(null, "", next === "issues" ? "/?view=issues" : "/"); + const title = next === "issues" ? "Issues" : next === "plans" ? "Plans" : "Review"; + document.title = `${title} · codeboost`; + history.replaceState(null, "", next === "review" ? "/" : `/?view=${next}`); if (next === "issues" && !issuesRequested) loadIssues(); + if (next === "plans") renderPlans(); } const issueTime = (value) => esc(new Date(value).toLocaleString()); function issueStatus() { @@ -991,11 +1232,64 @@ $("issues-link").onclick = (event) => { event.preventDefault(); showView("issues"); }; +$("plans-link").onclick = (event) => { + event.preventDefault(); + showView("plans"); +}; +$("plans-refresh").onclick = async () => { + if (planRefreshPending || planImportPending) return; + if (busy) return; + const generation = ++planOperationGeneration; + let sharedGeneration = ++reviewGeneration; + const mergeObservationOwner = mergeObservationGeneration; + mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; + mergePollState = null; + mergePollDelay = 2000; + planRefreshPending = true; + $("plans-refresh").setAttribute("aria-disabled", "true"); + $("plans-refresh").textContent = "Refreshing…"; + const statusOwner = claimPlanStatus("neutral", "Refreshing the current plan…"); + try { + rememberDraft(); + const updated = await api("/api/review"); + if (generation !== planOperationGeneration || sharedGeneration !== reviewGeneration) return; + sharedGeneration = ++reviewGeneration; + mergeGeneration++; + if (mergePollTimer) clearTimeout(mergePollTimer); + mergePollTimer = null; + mergePollState = null; + mergePollDelay = 2000; + rememberDraft(); + const newerQueue = newerMergeQueue(updated, mergeObservationOwner); + data = preserveSettledQuestionAnswers(newerQueue ? withMergeQueue(updated, newerQueue) : updated); + render(); + if (newerQueue) showMergeQueueStatus(newerQueue); + setPlanStatus(statusOwner, "good", `✓ Revision r${data.plan.revision} is current.`); + } catch (error) { + if (generation !== planOperationGeneration || sharedGeneration !== reviewGeneration) return; + setPlanStatus(statusOwner, "bad", `✕ Could not refresh the current plan. ${error.message}`); + renderMerge(); + } finally { + const current = generation === planOperationGeneration && sharedGeneration === reviewGeneration; + if (!current) + setPlanStatus(statusOwner, "neutral", ""); + if (generation === planOperationGeneration) { + planRefreshPending = false; + $("plans-refresh").removeAttribute("aria-disabled"); + $("plans-refresh").textContent = "Refresh plan"; + } + } +}; +$("plan-file").onchange = renderPlanFile; +$("plan-import-form").onsubmit = importPlan; $("issues-refresh").onclick = () => loadIssues(); $("issues-list").onclick = (event) => { const button = event.target.closest("button.issue-trust"); if (button) changeIssueTrust(button); }; -showView(new URLSearchParams(location.search).get("view") === "issues" ? "issues" : "review"); +const initialView = new URLSearchParams(location.search).get("view"); +showView(["issues", "plans"].includes(initialView) ? initialView : "review"); await refresh(); diff --git a/web/public/index.html b/web/public/index.html index 2e883cff..6deb37cd 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -41,7 +41,7 @@

A little more room to review

> @@ -61,6 +61,36 @@

A little more room to review

+
Review Loading…
diff --git a/web/public/style.css b/web/public/style.css index dc2363cf..f1ce00da 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -60,6 +60,20 @@ textarea, select { font: inherit; } +input[type="file"] { + width: 100%; + color: var(--muted); +} +input[type="file"]::file-selector-button { + min-height: 28px; + margin-right: 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--raised); + color: var(--text); + padding: 4px 10px; + cursor: pointer; +} button, select { min-height: 28px; @@ -714,6 +728,109 @@ body.resizing-conversation { cursor: col-resize; user-select: none; } margin-left: auto; overflow-wrap: anywhere; } +#plans-status { + margin-left: auto; + overflow-wrap: anywhere; +} +.plans-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + min-height: 0; + flex: 1; + overflow: hidden; +} +.plan-document { + overflow: auto; + padding: 24px; +} +.plan-document-heading { + display: flex; + align-items: flex-start; + gap: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--line); +} +.plan-document-heading > div { + flex: 1; + min-width: 0; +} +.revision-badge { + border: 1px solid var(--line); + color: var(--muted); + padding: 4px 8px; +} +.plan-questions { + border-bottom: 1px solid var(--line); + padding: 12px 0; +} +.plan-card { + padding: 20px 0; + border-bottom: 1px solid var(--line); +} +.plan-card > header { + display: flex; + align-items: baseline; + gap: 12px; +} +.plan-card h2, +.plan-card h3 { + margin: 0; + font-weight: 500; +} +.plan-card h3 { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} +.plan-dependencies code { + margin-left: 8px; + color: var(--text); +} +.plan-card-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 16px; +} +.plan-detail-list { + list-style: none; + padding: 0; + margin: 8px 0 0; +} +.plan-detail-list li { + display: grid; + grid-template-columns: minmax(88px, max-content) 1fr; + gap: 12px; + padding: 6px 0; + border-top: 1px solid var(--line); +} +.plan-detail-list span { + overflow-wrap: anywhere; +} +.plan-file-path { + display: flex; + gap: 8px; +} +.plan-import { + overflow: auto; + border-left: 1px solid var(--line); + background: var(--surface); +} +#plan-import-form { + padding: 16px; +} +#plan-import-form label { + display: block; + margin: 20px 0 8px; + font-weight: 500; +} +#plan-file-details { + overflow-wrap: anywhere; +} +#plan-import { + width: 100%; + margin-top: 12px; +} .issues-pane { flex: 1; overflow: auto;