diff --git a/docs/architecture.md b/docs/architecture.md index be98522e..248f7b69 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, 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:** plan and linking library, SQLite store, review screen, plan display/import/authoring 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:** 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. + - **Not built:** applying draft or suggestion results from the screen, 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, 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 | +| Browser screen | `web/public/*` | Review, Issues and Plans screens. Plans displays and imports revisions, and starts, polls, renders and dismisses draft or suggestion requests through the planning API. Applying those results remains future work. 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); Plans displays and imports revisions, while authoring and suggestion controls remain future work | +| 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 starts and displays authoring requests, while applying their results remains 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 | 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. | +| G | Planning screen | G1 and G2 built (#145, PRs #148 and #150): display/import the current plan, request a next-revision draft or suggestion cards, and render or dismiss the result. 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, plan-authoring, suggestion or Apply 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 runner start, resume, publish or plan-result Apply controls yet. Planning authoring and suggestions are available when the planning provider is configured. 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 index a88f066f..244826fa 100644 --- a/docs/implementation/planning-screen.md +++ b/docs/implementation/planning-screen.md @@ -1,9 +1,11 @@ # Planning screen -**Lane and step:** lane G, step G1. Tracking issue: #145. +**Lane and steps:** lane G, G1 and G2. 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. +G2 adds browser controls for the existing draft and suggestion request lifecycles. The screen can ask for a complete next-revision draft or independent edit cards, poll the durable request, render the result and dismiss it. It still cannot apply either result; Apply remains G3. + ## 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. @@ -18,4 +20,14 @@ The Store validates schema, issue identity, revision, paths, dependencies and co `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. +## Authoring and suggestions + +The guidance field is shared by the two explicit request types. **Draft next revision** starts `/api/plan/drafts`; **Suggest edits** starts `/api/plan/suggestions`. Each request is bound to the displayed revision and repository snapshot and carries a UUID action ID. If the start response is ambiguous, the matching control retries that exact body and action ID; the other request type stays unavailable until the outcome is known. + +After start, the screen polls only the durable request resource with bounded backoff. A lifecycle transition resets the interval. Poll updates replace only the request status and result: they never write the guidance field. The active control uses `aria-disabled` with an in-flight guard, so keyboard focus stays in place and repeat activation cannot start a second invocation. Dismiss uses the request's cancel endpoint and its own stable action ID. + +Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. Before rendering a ready result, the browser validates the complete v1 plan or edit-reply structure, including exact object fields, bounded nested file/check/item data and each edit operation's used and null payloads. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its lifecycle is invalidated or consumed, or its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan. + +`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, failed states with impossible output, consumed-result stale labeling, rejection of mismatched bindings, wrong-operation payloads, incomplete edit cards and incomplete nested draft data, hostile markup and attribute text in both result types, retained completed history and invalidated-history labeling, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action. + +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-authoring.spec.ts b/test/browser/plans-authoring.spec.ts new file mode 100644 index 00000000..2eda984f --- /dev/null +++ b/test/browser/plans-authoring.spec.ts @@ -0,0 +1,590 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createDemo } from '../../scripts/demo.ts'; +import { startServer, type PlanningDeps } from '../../web/server.ts'; +import type { AuthorProvider, AuthorRequest } from '../../core/planning-author.ts'; +import type { EditReply, Plan } from '../../core/plan.ts'; + +let root: string, app: Awaited>; +type Invocation = { request: AuthorRequest; signal: AbortSignal; resolve: (source: string) => void }; + +function planning() { + const invocations: Invocation[] = []; + const provider: AuthorProvider = { invoke: (request, signal) => new Promise((resolve, reject) => { + invocations.push({ request, signal, resolve }); + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }) }; + const deps: PlanningDeps = { + provider, + describe: () => ({ + repo: { name: 'retry-service', baseRef: 'main' }, + issue: { number: 3, title: 'Retries', body: '', comments: [] }, + approvedLessons: [], + validate: () => undefined, + }), + }; + return { deps, invocations }; +} + +test.beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'codeboost-plans-authoring-browser-')); +}); +test.afterEach(async () => { + await app?.close(); + rmSync(root, { recursive: true, force: true }); +}); + +async function openPlans(page: Page, deps: PlanningDeps) { + app = await startServer(createDemo(join(root, 'demo')), 0, undefined, undefined, undefined, undefined, undefined, deps); + await page.goto(app.url); + await page.getByRole('link', { name: 'Plans', exact: true }).click(); +} + +function suggestions(revision = 1): EditReply { + return { + schema_version: 1, + base_revision: revision, + reply: 'The retry title can state the operator-visible outcome more directly.', + edits: [{ + op: 'set_field', item: 'P1', summary: 'Name the retry ceiling', reason: 'Operators need to know that retries stay bounded.', + field: 'title', value: 'Expose the bounded retry ceiling', file: null, check: null, check_index: null, depends_on: null, new_item: null, + }], + }; +} + +function draft(current: Plan): Plan { + return { + ...structuredClone(current), + revision: current.revision + 1, + summary: 'Make retry limits visible to operators', + items: current.items.map((item, index) => index === 0 ? { ...item, title: 'Expose the bounded retry ceiling' } : item), + }; +} + +test('renders suggestion cards and preserves guidance edited after submission', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const guidance = page.getByLabel('Guidance'); + await guidance.fill('Make the plan clearer.'); + const suggest = page.locator('#plan-suggest'); + await suggest.focus(); + await suggest.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + + await expect.poll(() => invocations.length).toBe(1); + await expect(suggest).toBeFocused(); + await expect(suggest).toHaveAttribute('aria-disabled', 'true'); + await expect(page.locator('#plan-draft')).toHaveAttribute('aria-disabled', 'true'); + await page.mouse.move(0, 0); + await expect.poll(async () => { + const disabledColors = await Promise.all([suggest, page.locator('#plan-draft')].map(locator => + locator.evaluate(element => { + const style = getComputedStyle(element); + return { background: style.backgroundColor, border: style.borderColor, color: style.color }; + }))); + return JSON.stringify(disabledColors[0]) === JSON.stringify(disabledColors[1]); + }).toBe(true); + await guidance.fill('Keep these newer notes while the request runs.'); + invocations[0]!.resolve(JSON.stringify(suggestions())); + + await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible(); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toContainText('Set P1.title to Expose the bounded retry ceiling'); + await expect(guidance).toHaveValue('Keep these newer notes while the request runs.'); + await page.screenshot({ path: 'test-results/plans-authoring-desktop.png', fullPage: true }); + expect(invocations[0]!.request).toMatchObject({ mode: 'suggest', revision: 1 }); + expect(invocations[0]!.request.prompt).toContain('Make the plan clearer.'); +}); + +test('accepts complete payloads for every edit operation', async ({ page }) => { + const { deps } = planning(); + await openPlans(page, deps); + const current = app.service.store.getPlan(app.service.config.identity); + const snapshotId = app.service.load().snapshot.id; + const card = (op: EditReply['edits'][number]['op'], payload: Record = {}, item = 'P1') => ({ + op, item, summary: `Exercise ${op}`, reason: `The ${op} payload is complete.`, + field: null, value: null, file: null, check: null, check_index: null, depends_on: null, new_item: null, ...payload, + }) as EditReply['edits'][number]; + const newItem = { ...structuredClone(current.items[0]!), id: 'P4', depends_on: ['P1'] }; + const reply: EditReply = { ...suggestions(), reply: '😀'.repeat(4000), edits: [ + card('add_item', { new_item: newItem }, 'P4'), + card('set_field', { field: 'intent', value: 'Explain the bounded retry behavior.' }), + card('add_file', { file: { path: 'added.ts', kind: 'add', renamed_from: null, change: 'Add the helper.' } }), + card('update_file', { file: { path: 'retry.ts', kind: 'edit', renamed_from: null, change: 'Clarify retries.' } }), + card('remove_file', { value: 'retry.ts' }), + card('add_check', { check: { type: 'check', text: 'Retries stay bounded.' } }), + card('remove_check', { check_index: 0 }), + card('set_depends', { depends_on: [] }), + card('remove_item', {}, 'P3'), + ] }; + const requestId = '12345678-1234-4123-8123-123456789abc'; + await page.route('**/api/plan/suggestions', route => route.fulfill({ + status: 200, contentType: 'application/json', body: JSON.stringify({ result: { requestId } }), + })); + await page.route(`**/api/plan/suggestions/${requestId}`, route => route.fulfill({ + status: 200, contentType: 'application/json', body: JSON.stringify({ + mode: 'suggest', state: 'ready', revision: 1, snapshotId, reply, reason: null, + }), + })); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible(); + await expect(page.locator('.plan-author-reply')).toHaveText(reply.reply); + await expect(page.locator('.suggestion-row')).toHaveCount(9); + await expect(page.getByRole('button', { name: 'Suggest edits' })).not.toHaveAttribute('aria-disabled', 'true'); +}); + +test('renders a whole next-revision draft without changing the current plan', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const current = app.service.store.getPlan(app.service.config.identity); + const snapshotId = app.service.load().snapshot.id; + let reads = 0; + await page.route('**/api/plan/drafts/*', async route => { + reads++; + if (reads === 1) { + const malformed = { ...draft(current), items: [{ id: 'P1', title: 'Incomplete item' }] }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + state: 'ready', revision: 1, snapshotId, plan: malformed, reason: null, + }) }); + return; + } + await route.continue(); + }); + await page.getByLabel('Guidance').fill('Rewrite the plan around operator visibility.'); + await page.getByRole('button', { name: 'Draft next revision' }).click(); + await expect.poll(() => invocations.length).toBe(1); + await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Draft Make retry limits visible to operators' })).toHaveCount(0); + invocations[0]!.resolve(JSON.stringify(draft(current))); + + await expect(page.getByRole('status').filter({ hasText: 'Draft ready for r1.' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Draft Make retry limits visible to operators' })).toContainText('P1 Expose the bounded retry ceiling'); + await expect(page.locator('#plans-revision')).toHaveText('r1'); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(1); +}); + +test('retries an ambiguous suggestion start with the exact request', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const sent: unknown[] = []; + await page.route('**/api/plan/suggestions', async route => { + sent.push(route.request().postDataJSON()); + if (sent.length === 1) await route.abort('failed'); + else await route.continue(); + }); + const guidance = page.getByLabel('Guidance'); + await guidance.fill('Use the submitted guidance.'); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'request outcome is unknown' })).toBeVisible(); + await guidance.fill('Do not replace these newer notes.'); + await page.getByRole('button', { name: 'Retry suggestion request' }).click(); + await expect.poll(() => invocations.length).toBe(1); + expect(sent).toHaveLength(2); + expect(sent[1]).toEqual(sent[0]); + expect(invocations[0]!.request.prompt).toContain('Use the submitted guidance.'); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible(); + await expect(guidance).toHaveValue('Do not replace these newer notes.'); +}); + +test('reports a definite stale start refusal after an import advances the plan', async ({ page }) => { + const { deps, invocations } = planning(); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + const describe = deps.describe; + deps.describe = async signal => { arrived(); await held; return describe(signal); }; + await openPlans(page, deps); + const current = app.service.store.getPlan(app.service.config.identity); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await captured; + await page.getByLabel('Plan file').setInputFiles({ + name: 'next-plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(draft(current))), + }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.locator('#plans-revision')).toHaveText('r2'); + release(); + + await expect(page.getByRole('status').filter({ hasText: 'Suggestion failed.' })).toContainText('Stale plan revision or snapshot'); + await expect(page.getByRole('status').filter({ hasText: 'generated for r1' })).toHaveCount(0); + expect(invocations).toHaveLength(0); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(2); +}); + +test('keeps ownership while a malformed status response is retried', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + let reads = 0; + await page.route('**/api/plan/suggestions/*', async route => { + reads++; + if (reads === 1) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ state: 'surprise' }) }); + return; + } + await route.continue(); + }); + const suggest = page.locator('#plan-suggest'); + await suggest.click(); + await expect.poll(() => invocations.length).toBe(1); + await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible(); + await expect(suggest).toHaveAttribute('aria-disabled', 'true'); + await suggest.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + expect(invocations).toHaveLength(1); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible(); +}); + +test('rejects failed output and marks consumed output stale', async ({ page }) => { + const { deps } = planning(); + await openPlans(page, deps); + const snapshotId = app.service.load().snapshot.id; + let reads = 0; + let releaseConsumed!: () => void; + const consumedHeld = new Promise(resolve => { releaseConsumed = resolve; }); + await page.route('**/api/plan/suggestions', route => route.fulfill({ + status: 200, contentType: 'application/json', body: JSON.stringify({ + result: { requestId: '12345678-1234-4123-8123-123456789abc' }, + }), + })); + await page.route('**/api/plan/suggestions/*', async route => { + reads++; + if (reads > 1) await consumedHeld; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + mode: 'suggest', state: reads === 1 ? 'failed' : 'consumed', revision: 1, snapshotId, + reply: suggestions(), reason: reads === 1 ? 'The provider refused the request.' : null, + }) }); + }); + const suggest = page.locator('#plan-suggest'); + await suggest.click(); + await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible(); + await expect(suggest).toHaveAttribute('aria-disabled', 'true'); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0); + await expect.poll(() => reads).toBeGreaterThanOrEqual(2); + releaseConsumed(); + await expect(page.getByRole('status').filter({ hasText: 'Stale suggestions · generated for r1' })).toBeVisible(); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Dismiss suggestions' })).toHaveCount(0); + await expect(suggest).not.toHaveAttribute('aria-disabled', 'true'); +}); + +test('rejects a status response for the wrong planning operation', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const current = app.service.store.getPlan(app.service.config.identity); + const snapshotId = app.service.load().snapshot.id; + let reads = 0; + await page.route('**/api/plan/suggestions/*', async route => { + reads++; + if (reads === 1) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + mode: 'draft', state: 'ready', revision: 1, snapshotId, reply: suggestions(), reason: null, + }) }); + return; + } + await route.continue(); + }); + const suggest = page.locator('#plan-suggest'); + await suggest.click(); + await expect.poll(() => invocations.length).toBe(1); + await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible(); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0); + await expect(suggest).toHaveAttribute('aria-disabled', 'true'); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible(); + + let draftReads = 0; + await page.route('**/api/plan/drafts/*', async route => { + draftReads++; + if (draftReads === 1) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + mode: 'suggest', state: 'ready', revision: 1, snapshotId, plan: draft(current), reason: null, + }) }); + return; + } + await route.continue(); + }); + await page.getByRole('button', { name: 'Draft next revision' }).click(); + await expect.poll(() => invocations.length).toBe(2); + await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Draft Make retry limits visible to operators' })).toHaveCount(0); + invocations[1]!.resolve(JSON.stringify(draft(current))); + await expect(page.getByRole('status').filter({ hasText: 'Draft ready for r1.' })).toBeVisible(); +}); + +test('marks invalidated output stale when it moves into history', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const current = app.service.store.getPlan(app.service.config.identity); + const snapshotId = app.service.load().snapshot.id; + let reads = 0; + await page.route('**/api/plan/suggestions/*', async route => { + reads++; + if (reads === 1) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + mode: 'suggest', state: 'invalidated', revision: 1, snapshotId, reply: suggestions(), + reason: 'The planning checkpoint changed.', + }) }); + return; + } + await route.continue(); + }); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect.poll(() => invocations.length).toBe(1); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions became stale.' })).toBeVisible(); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await page.getByRole('button', { name: 'Draft next revision' }).click(); + await expect.poll(() => invocations.length).toBe(2); + const historical = page.locator('.plan-author-result-heading').filter({ hasText: 'Earlier suggested edits' }); + await expect(historical).toContainText('! Stale'); + await expect(historical).not.toContainText('Earlier result'); + invocations[1]!.resolve(JSON.stringify(draft(current))); + await expect(page.getByRole('status').filter({ hasText: 'Draft ready for r1.' })).toBeVisible(); +}); + +test('rejects a status observation bound to another snapshot', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + let reads = 0; + await page.route('**/api/plan/suggestions/*', async route => { + reads++; + if (reads === 1) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + mode: 'suggest', state: 'ready', revision: 1, snapshotId: 'foreign-snapshot', reply: suggestions(), reason: null, + }) }); + return; + } + await route.continue(); + }); + const suggest = page.locator('#plan-suggest'); + await suggest.click(); + await expect.poll(() => invocations.length).toBe(1); + await expect(page.getByRole('status').filter({ hasText: 'belonged to another plan context' })).toBeVisible(); + await expect(suggest).toHaveAttribute('aria-disabled', 'true'); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible(); +}); + +test('rejects an operation-specific payload that is unsafe to render', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const snapshotId = app.service.load().snapshot.id; + let reads = 0; + await page.route('**/api/plan/suggestions/*', async route => { + reads++; + if (reads <= 2) { + const reply = suggestions(); + const malformedEdit = reads === 1 + ? { ...reply.edits[0], op: 'add_item', field: null, value: null, new_item: null } + : { ...reply.edits[0], op: 'add_file', field: null, value: null, file: { path: 'partial.ts' } }; + const malformed = { ...reply, edits: [malformedEdit] }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + mode: 'suggest', state: 'ready', revision: 1, snapshotId, reply: malformed, reason: null, + }) }); + return; + } + await route.continue(); + }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + const suggest = page.locator('#plan-suggest'); + await suggest.click(); + await expect.poll(() => invocations.length).toBe(1); + await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible(); + await expect(suggest).toHaveAttribute('aria-disabled', 'true'); + await expect.poll(() => reads).toBeGreaterThanOrEqual(2); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0); + expect(errors).toEqual([]); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible(); +}); + +test('renders hostile suggestion and draft fields only as literal text', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const current = app.service.store.getPlan(app.service.config.identity); + const hostileReply = ''; + const hostileSummary = '\">'; + const hostileReason = ''; + const hostileValue = '

'; + const reply = suggestions(); + reply.reply = hostileReply; + reply.edits[0] = { ...reply.edits[0]!, summary: hostileSummary, reason: hostileReason, value: hostileValue }; + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect.poll(() => invocations.length).toBe(1); + invocations[0]!.resolve(JSON.stringify(reply)); + await expect(page.locator('#plan-author-result')).toContainText(hostileReply); + await expect(page.locator('#plan-author-result')).toContainText(hostileSummary); + await expect(page.locator('#plan-author-result')).toContainText(hostileReason); + await expect(page.locator('#plan-author-result')).toContainText(hostileValue); + + const hostileDraftSummary = '
'; + const hostileDraftTitle = '\">'; + const hostileDraft = draft(current); + hostileDraft.summary = hostileDraftSummary; + hostileDraft.items[0] = { ...hostileDraft.items[0]!, title: hostileDraftTitle }; + await page.getByRole('button', { name: 'Draft next revision' }).click(); + await expect.poll(() => invocations.length).toBe(2); + invocations[1]!.resolve(JSON.stringify(hostileDraft)); + await expect(page.locator('#plan-author-result')).toContainText(hostileDraftSummary); + await expect(page.locator('#plan-author-result')).toContainText(hostileDraftTitle); + for (const selector of ['#g2-injected-image', '#g2-injected-svg', '#g2-injected-script', '#g2-injected-button', '#g2-draft-image', '#g2-draft-button']) + await expect(page.locator(selector)).toHaveCount(0); + expect(await page.evaluate(() => (window as unknown as Record).__g2Injected)).toBeUndefined(); +}); + +test('keeps a completed result when the next request is refused', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect.poll(() => invocations.length).toBe(1); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toBeVisible(); + await page.route('**/api/plan/drafts', route => route.fulfill({ + status: 409, contentType: 'application/json', body: JSON.stringify({ error: 'Drafting is temporarily unavailable.' }), + })); + await page.getByRole('button', { name: 'Draft next revision' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Draft failed.' })).toContainText('temporarily unavailable'); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toBeVisible(); + await expect(page.getByText('Earlier suggested edits')).toBeVisible(); +}); + +test('dismisses ready suggestions without changing the plan', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect.poll(() => invocations.length).toBe(1); + invocations[0]!.resolve(JSON.stringify(suggestions())); + const dismiss = page.locator('#plan-author-dismiss'); + await expect(dismiss).toBeVisible(); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + await page.route('**/api/plan/suggestions/*/cancel', async route => { arrived(); await held; await route.continue(); }); + await dismiss.focus(); + await dismiss.click(); + await captured; + await expect(dismiss).toBeFocused(); + await expect(dismiss).toHaveAttribute('aria-disabled', 'true'); + release(); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions dismissed.' })).toBeVisible(); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(1); +}); + +test('ignores a ready poll response that returns after dismissal completes', async ({ page }) => { + await page.addInitScript(() => { + const nativeSetTimeout = window.setTimeout.bind(window); + window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + if (typeof handler === 'function' && handler.toString().includes('pollPlanAuthor')) + (window as typeof window & { __planAuthorPoll?: () => void }).__planAuthorPoll = handler as () => void; + return nativeSetTimeout(handler, timeout, ...args); + }) as typeof window.setTimeout; + }); + const { deps, invocations } = planning(); + await openPlans(page, deps); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect.poll(() => invocations.length).toBe(1); + invocations[0]!.resolve(JSON.stringify(suggestions())); + const dismiss = page.locator('#plan-author-dismiss'); + await expect(dismiss).toBeVisible(); + + let release!: () => void, arrived!: () => void, requestId = ''; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + let heldOldPoll = false; + await page.route('**/api/plan/suggestions/*', async route => { + if (route.request().method() !== 'GET' || heldOldPoll) { await route.fallback(); return; } + heldOldPoll = true; + requestId = new URL(route.request().url()).pathname.split('/').at(-1) ?? ''; + const response = await route.fetch(); + arrived(); + await held; + await route.fulfill({ response }); + }); + await page.evaluate(() => { + (window as typeof window & { __planAuthorPoll?: () => void }).__planAuthorPoll?.(); + }); + await captured; + + await dismiss.click(); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions dismissed.' })).toBeVisible(); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0); + expect(app.service.store.getSuggestions(app.service.config.identity, requestId).state).toBe('cancelled'); + release(); + + await expect(page.getByRole('status').filter({ hasText: 'Suggestions dismissed.' })).toBeVisible(); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0); + expect(app.service.store.getSuggestions(app.service.config.identity, requestId).state).toBe('cancelled'); +}); + +test('blocks repeat dismissal until an ambiguous cancellation is reconciled', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect.poll(() => invocations.length).toBe(1); + invocations[0]!.resolve(JSON.stringify(suggestions())); + const dismiss = page.locator('#plan-author-dismiss'); + await expect(dismiss).toBeVisible(); + let cancels = 0, release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + await page.route('**/api/plan/suggestions/*/cancel', async route => { + cancels++; + if (cancels === 1) await route.abort('failed'); + else await route.continue(); + }); + await page.route('**/api/plan/suggestions/*', async route => { + if (route.request().method() !== 'GET') { await route.fallback(); return; } + const response = await route.fetch(); + arrived(); + await held; + await route.fulfill({ response }); + }); + await dismiss.click(); + await captured; + await expect(dismiss).toHaveAttribute('aria-disabled', 'true'); + await dismiss.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + expect(cancels).toBe(1); + release(); + await expect(page.getByRole('button', { name: 'Dismiss suggestions' })).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss suggestions' }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Suggestions dismissed.' })).toBeVisible(); + expect(cancels).toBe(2); +}); + +test('marks a delayed suggestion poll stale after an import advances the plan', async ({ page }) => { + const { deps, invocations } = planning(); + await openPlans(page, deps); + const current = app.service.store.getPlan(app.service.config.identity); + let release!: () => void, arrived!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const captured = new Promise(resolve => { arrived = resolve; }); + let heldReady = false; + await page.route('**/api/plan/suggestions/*', async route => { + const response = await route.fetch(); + const body = await response.json(); + if (!heldReady && body.state === 'ready') { + heldReady = true; + arrived(); + await held; + } + await route.fulfill({ response, body: JSON.stringify(body) }); + }); + await page.getByRole('button', { name: 'Suggest edits' }).click(); + await expect.poll(() => invocations.length).toBe(1); + invocations[0]!.resolve(JSON.stringify(suggestions())); + await captured; + + await page.getByLabel('Plan file').setInputFiles({ + name: 'next-plan.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(draft(current))), + }); + await page.getByRole('button', { name: 'Import next revision' }).click(); + await expect(page.locator('#plans-revision')).toHaveText('r2'); + release(); + + await expect(page.getByRole('status').filter({ hasText: 'Stale suggestions · generated for r1' })).toBeVisible(); + await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Dismiss suggestions' })).toHaveCount(0); + expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(2); +}); diff --git a/web/public/app.js b/web/public/app.js index 60f6c423..a00675f7 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -905,6 +905,11 @@ let planImportPending = false, planRefreshPending = false, planOperationGeneration = 0, planStatusGeneration = 0; +let planAuthorGeneration = 0, + planAuthorRequest = null; +const planAuthorHistory = []; +const planAuthorPollInitial = 500, + planAuthorPollMaximum = 5000; function claimPlanStatus(className, text) { const owner = ++planStatusGeneration; $("plans-status").className = className; @@ -924,12 +929,183 @@ function supersedePlanRefresh() { $("plans-refresh").textContent = "Refresh plan"; claimPlanStatus("neutral", ""); } +function planAuthorBusy() { + return ["starting", "pending", "cancelling"].includes(planAuthorRequest?.state); +} +function planAuthorStale(request = planAuthorRequest) { + return !!request && (["invalidated", "consumed"].includes(request.state) || !data?.plan || + request.revision !== data.plan.revision || request.snapshotId !== data.snapshot?.id); +} +function describePlanEdit(edit) { + switch (edit.op) { + case "add_item": return `Add ${edit.item}`; + case "remove_item": return `Remove ${edit.item}`; + case "set_field": return `Set ${edit.item}.${edit.field} to ${edit.value}`; + case "add_file": return `Add ${edit.file?.path} to ${edit.item}`; + case "update_file": return `Update ${edit.file?.path} in ${edit.item}`; + case "remove_file": return `Remove ${edit.value} from ${edit.item}`; + case "add_check": return `Add ${edit.check?.type} check to ${edit.item}`; + case "remove_check": return `Remove check ${Number(edit.check_index) + 1} from ${edit.item}`; + case "set_depends": return `Set ${edit.item} dependencies to ${(edit.depends_on || []).join(", ") || "none"}`; + default: return "Change the plan"; + } +} +function exactObject(value, keys) { + return !!value && typeof value === "object" && !Array.isArray(value) && + Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function boundedText(value, minimum, maximum) { + if (typeof value !== "string") return false; + let length = 0; + for (const _point of value) { + if (++length > maximum) return false; + } + return length >= minimum; +} +function validPlanId(value) { + return typeof value === "string" && /^P[1-9][0-9]{0,2}$/.test(value); +} +function validPlanFileForDisplay(file) { + return exactObject(file, ["path", "kind", "renamed_from", "change"]) && + boundedText(file.path, 1, 300) && /^[^/\\][^\\]*$/.test(file.path) && + ["edit", "add", "delete", "rename"].includes(file.kind) && + (file.renamed_from === null || boundedText(file.renamed_from, 0, 300)) && + boundedText(file.change, 1, 1200); +} +function validPlanCheckForDisplay(check) { + return exactObject(check, ["type", "text"]) && ["cmd", "check"].includes(check.type) && + boundedText(check.text, 1, 400); +} +function validPlanItemForDisplay(item) { + return exactObject(item, ["id", "title", "intent", "files", "acceptance", "depends_on"]) && + validPlanId(item.id) && boundedText(item.title, 1, 120) && boundedText(item.intent, 1, 600) && + Array.isArray(item.files) && item.files.length >= 1 && item.files.length <= 40 && item.files.every(validPlanFileForDisplay) && + Array.isArray(item.acceptance) && item.acceptance.length >= 1 && item.acceptance.length <= 10 && item.acceptance.every(validPlanCheckForDisplay) && + Array.isArray(item.depends_on) && item.depends_on.length <= 29 && item.depends_on.every(validPlanId); +} +function validPlanForDisplay(plan) { + return exactObject(plan, ["schema_version", "issue", "revision", "summary", "items", "questions"]) && + plan.schema_version === 1 && Number.isSafeInteger(plan.issue) && plan.issue >= 1 && + Number.isSafeInteger(plan.revision) && plan.revision >= 1 && boundedText(plan.summary, 1, 600) && + Array.isArray(plan.items) && plan.items.length >= 1 && plan.items.length <= 30 && plan.items.every(validPlanItemForDisplay) && + Array.isArray(plan.questions) && plan.questions.length <= 10 && plan.questions.every((question) => boundedText(question, 1, 400)); +} +function validPlanEditForDisplay(edit) { + const payloads = ["field", "value", "file", "check", "check_index", "depends_on", "new_item"]; + const used = { + add_item: ["new_item"], remove_item: [], set_field: ["field", "value"], + add_file: ["file"], update_file: ["file"], remove_file: ["value"], + add_check: ["check"], remove_check: ["check_index"], set_depends: ["depends_on"], + }; + const operationPayloads = Object.hasOwn(used, edit?.op) ? used[edit.op] : null; + if (!exactObject(edit, ["op", "item", "summary", "reason", ...payloads]) || !operationPayloads || + !validPlanId(edit.item) || !boundedText(edit.summary, 1, 160) || !boundedText(edit.reason, 1, 400) || + payloads.some((key) => operationPayloads.includes(key) ? edit[key] === null : edit[key] !== null)) return false; + switch (edit.op) { + case "add_item": return validPlanItemForDisplay(edit.new_item) && edit.new_item.id === edit.item; + case "remove_item": return true; + case "set_field": return ["title", "intent"].includes(edit.field) && boundedText(edit.value, 0, 1200); + case "add_file": + case "update_file": return validPlanFileForDisplay(edit.file); + case "remove_file": return boundedText(edit.value, 0, 1200); + case "add_check": return validPlanCheckForDisplay(edit.check); + case "remove_check": return Number.isSafeInteger(edit.check_index) && edit.check_index >= 0; + case "set_depends": return Array.isArray(edit.depends_on) && edit.depends_on.length <= 29 && edit.depends_on.every(validPlanId); + default: return false; + } +} +function validEditReplyForDisplay(reply, revision) { + return exactObject(reply, ["schema_version", "base_revision", "reply", "edits"]) && reply.schema_version === 1 && + Number.isSafeInteger(reply.base_revision) && reply.base_revision === revision && boundedText(reply.reply, 0, 4000) && + Array.isArray(reply.edits) && reply.edits.length <= 10 && reply.edits.every(validPlanEditForDisplay); +} +function validPlanAuthorStatus(status, request) { + const states = ["pending", "ready", "failed", "cancelled", "invalidated", "consumed"]; + const statusKeys = request.mode === "suggest" ? ["mode", "state", "revision", "snapshotId", "reply", "reason"] + : ["state", "revision", "snapshotId", "plan", "reason"]; + const validShape = exactObject(status, statusKeys); + const validMode = validShape && (request.mode === "suggest" ? status.mode === "suggest" : !("mode" in status)); + if (!validShape || !validMode || !states.includes(status.state) || + !Number.isSafeInteger(status.revision) || status.revision < 1 || + typeof status.snapshotId !== "string" || !status.snapshotId || + status.revision !== request.revision || status.snapshotId !== request.snapshotId || + !(status.reason === null || typeof status.reason === "string")) return false; + const terminalReason = ["failed", "cancelled", "invalidated"].includes(status.state); + if (terminalReason ? !(typeof status.reason === "string" && status.reason.length > 0) : status.reason !== null) return false; + const result = request.mode === "draft" ? status.plan : status.reply; + const validDraft = validPlanForDisplay(result) && result.issue === request.issue && result.revision === status.revision + 1; + const validReply = validEditReplyForDisplay(result, status.revision); + const validResult = request.mode === "draft" ? validDraft : validReply; + if (["pending", "failed"].includes(status.state)) return result === null; + if (["ready", "consumed"].includes(status.state)) return !!validResult; + return result === null || !!validResult; +} +function planAuthorResultMarkup(request, historical = false) { + if (request.state === "cancelled") return ""; + const stale = planAuthorStale(request); + const historyMark = historical ? ` ${stale ? "! Stale" : "– Earlier result"}` : ""; + let result = ""; + if (request.mode === "suggest" && request.reply) { + result = `
${historical ? "Earlier suggested edits" : "Suggested edits"} r${esc(request.revision)}${historyMark}
+ ${request.reply.reply ? `

${esc(request.reply.reply)}

` : ""} + ${request.reply.edits.map((edit) => `
${esc(edit.item)}

${esc(edit.summary)}

${esc(edit.reason)}

${esc(describePlanEdit(edit))}

`).join("") || '

No edits were suggested.

'}`; + } else if (request.mode === "draft" && request.plan) { + result = `
${historical ? "Earlier draft revision" : "Draft revision"} r${esc(request.plan.revision)}${historyMark}
+

${esc(request.plan.summary)}

+
    ${request.plan.items.map((item) => `
  • ${esc(item.id)} ${esc(item.title)}
  • `).join("")}
`; + } + const dismissible = request.state === "cancelling" || (request.state === "ready" && !stale); + if (!historical && result && request.requestId && dismissible) + result += `
`; + return result; +} +function renderPlanAuthor() { + const restoreDismissFocus = document.activeElement?.id === "plan-author-dismiss"; + const request = planAuthorRequest, unavailable = !data?.plan; + const busyAuthor = planAuthorBusy(), uncertain = request?.state === "uncertain"; + for (const [id, mode, label] of [["plan-draft", "draft", "Draft next revision"], ["plan-suggest", "suggest", "Suggest edits"]]) { + const button = $(id); + const blocked = unavailable || busyAuthor || (uncertain && request.mode !== mode); + if (blocked) button.setAttribute("aria-disabled", "true"); + else button.removeAttribute("aria-disabled"); + button.textContent = request?.state === "starting" && request.mode === mode ? "Starting…" + : request?.state === "pending" && request.mode === mode ? "Working…" + : request?.state === "cancelling" && request.mode === mode ? "Cancelling…" + : uncertain && request.mode === mode ? `Retry ${mode === "draft" ? "draft" : "suggestion"} request` + : label; + } + if (!request) { + $("plan-author-status").className = "neutral"; + $("plan-author-status").textContent = unavailable ? "Load a plan before asking the agent." : ""; + $("plan-author-result").innerHTML = ""; + return; + } + const noun = request.mode === "draft" ? "draft" : "suggestions", stale = planAuthorStale(request); + const hasResult = !!(request.reply || request.plan); + let tone = "neutral", message = ""; + if (request.state === "starting") message = `Starting ${noun}…`; + else if (request.state === "uncertain") { tone = "warn"; message = `! The ${noun} request outcome is unknown. Retry the exact request.`; } + else if (request.state === "failed") { tone = "bad"; message = `✕ ${request.mode === "draft" ? "Draft" : "Suggestion"} failed. ${request.reason || "Try again."}`; } + else if (request.state === "cancelling") message = `Cancelling ${noun}…`; + else if (request.state === "cancelled") message = `– ${request.mode === "draft" ? "Draft" : "Suggestions"} dismissed. ${request.reason || ""}`.trim(); + else if (request.state === "invalidated") { tone = "warn"; message = `! ${request.mode === "draft" ? "Draft" : "Suggestions"} became stale. ${request.reason || "Reload and ask again."}`; } + else if (stale) { tone = "warn"; message = `! Stale ${hasResult ? `${noun} · generated` : `${noun} request · started`} for r${request.revision}`; } + else if (request.state === "pending") message = `Agent is preparing ${noun} for r${request.revision}…`; + else if (request.state === "ready") { tone = "good"; message = `✓ ${request.mode === "draft" ? "Draft" : "Suggestions"} ready for r${request.revision}.`; } + if (request.observationError) message += ` Status check failed: ${request.observationError}`; + $("plan-author-status").className = tone; + $("plan-author-status").textContent = message; + $("plan-author-result").innerHTML = [planAuthorResultMarkup(request), ...planAuthorHistory.map(entry => planAuthorResultMarkup(entry, true))].join(""); + $("plan-author-dismiss")?.addEventListener("click", dismissPlanAuthor); + if (restoreDismissFocus) $("plan-author-dismiss")?.focus(); +} function renderPlans() { if (!data?.plan) { $("plans-summary").textContent = "Plan unavailable"; $("plans-revision").textContent = ""; $("plans-items").innerHTML = ""; $("plans-questions").innerHTML = ""; + renderPlanAuthor(); return; } const plan = data.plan; @@ -949,6 +1125,7 @@ function renderPlans() {

Acceptance

    ${item.acceptance.map((check) => `
  • ${esc(check.type)}${esc(check.text)}
  • `).join("")}
`).join(""); + renderPlanAuthor(); } function planFileFormat(name) { return /\.ya?ml$/i.test(name) ? "yaml" : /\.json$/i.test(name) ? "json" : null; @@ -1048,6 +1225,118 @@ async function importPlan(event) { $("plan-import").textContent = "Import next revision"; } } +function schedulePlanAuthorPoll(generation, delay = planAuthorRequest?.pollDelay ?? planAuthorPollInitial) { + const request = planAuthorRequest; + if (!request || request.generation !== generation || !request.requestId) return; + if (request.pollTimer) clearTimeout(request.pollTimer); + request.pollTimer = setTimeout(() => pollPlanAuthor(generation), delay); +} +async function pollPlanAuthor(generation) { + const request = planAuthorRequest; + if (!request || request.generation !== generation || !request.requestId) return; + request.pollTimer = null; + const operationGeneration = request.operationGeneration; + try { + const status = await api(`/api/plan/${request.mode === "draft" ? "drafts" : "suggestions"}/${request.requestId}`); + if (planAuthorRequest !== request || request.generation !== generation || request.operationGeneration !== operationGeneration) return; + if (!validPlanAuthorStatus(status, request)) throw new Error("Planning status response was incomplete, invalid, or belonged to another plan context."); + const changed = request.state !== status.state; + const previous = { state: request.state, reason: request.reason, reply: request.reply, plan: request.plan, + observationError: request.observationError, pollDelay: request.pollDelay }; + request.state = status.state; + request.reason = status.reason; + request.observationError = null; + if (request.mode === "draft") request.plan = status.plan; + else request.reply = status.reply; + request.pollDelay = changed ? planAuthorPollInitial : Math.min(request.pollDelay * 2, planAuthorPollMaximum); + try { renderPlanAuthor(); } + catch (error) { Object.assign(request, previous); throw error; } + request.operationGeneration++; + if (request.state === "pending") schedulePlanAuthorPoll(generation); + } catch (error) { + if (planAuthorRequest !== request || request.generation !== generation || request.operationGeneration !== operationGeneration) return; + request.observationError = error.message; + request.pollDelay = Math.min(request.pollDelay * 2, planAuthorPollMaximum); + renderPlanAuthor(); + schedulePlanAuthorPoll(generation); + } +} +async function startPlanAuthor(mode) { + if (!data?.plan || planAuthorBusy()) return; + if (planImportPending || planRefreshPending || busy) { + $("plan-author-status").className = "warn"; + $("plan-author-status").textContent = "! Wait for the current plan or review action before asking the agent."; + return; + } + if (planAuthorRequest?.state === "uncertain" && planAuthorRequest.mode !== mode) return; + const retry = planAuthorRequest?.state === "uncertain" && planAuthorRequest.mode === mode; + const requestBody = retry ? planAuthorRequest.request : { + expectedRevision: data.plan.revision, + snapshotId: data.snapshot.id, + feedback: $("plan-feedback").value, + actionId: crypto.randomUUID(), + }; + if (planAuthorRequest?.reply || planAuthorRequest?.plan) + planAuthorHistory.unshift({ ...planAuthorRequest, pollTimer: null }); + const request = { + generation: ++planAuthorGeneration, + mode, + state: "starting", + revision: requestBody.expectedRevision, + snapshotId: requestBody.snapshotId, + issue: data.plan.issue, + request: requestBody, + requestId: null, + reason: null, + reply: null, + plan: null, + observationError: null, + pollDelay: planAuthorPollInitial, + pollTimer: null, + cancelActionId: null, + operationGeneration: 0, + }; + planAuthorRequest = request; + renderPlanAuthor(); + try { + const response = await api(`/api/plan/${mode === "draft" ? "drafts" : "suggestions"}`, requestBody); + if (planAuthorRequest !== request) return; + const requestId = response.result?.requestId; + if (typeof requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)) + throw new Error("The planning request started without a valid ID."); + request.requestId = requestId; + request.state = "pending"; + renderPlanAuthor(); + schedulePlanAuthorPoll(request.generation, 0); + } catch (error) { + if (planAuthorRequest !== request) return; + const ambiguous = !Number.isSafeInteger(error?.status) || error.status === 503 || error.outcomeUnknown === true; + request.state = ambiguous ? "uncertain" : "failed"; + request.reason = error.message; + renderPlanAuthor(); + } +} +async function dismissPlanAuthor() { + const request = planAuthorRequest; + if (!request?.requestId || planAuthorBusy()) return; + request.cancelActionId ??= crypto.randomUUID(); + request.operationGeneration++; + request.state = "cancelling"; + renderPlanAuthor(); + try { + await api(`/api/plan/${request.mode === "draft" ? "drafts" : "suggestions"}/${request.requestId}/cancel`, { actionId: request.cancelActionId }); + if (planAuthorRequest !== request) return; + request.observationError = null; + schedulePlanAuthorPoll(request.generation, 0); + } catch (error) { + if (planAuthorRequest !== request) return; + const ambiguous = !Number.isSafeInteger(error?.status) || error.status === 503 || error.outcomeUnknown === true; + if (!ambiguous) request.cancelActionId = null; + request.observationError = error.message; + renderPlanAuthor(); + schedulePlanAuthorPoll(request.generation); + } +} function showView(next) { view = next; $("review-view").hidden = next !== "review"; @@ -1284,6 +1573,8 @@ $("plans-refresh").onclick = async () => { }; $("plan-file").onchange = renderPlanFile; $("plan-import-form").onsubmit = importPlan; +$("plan-draft").onclick = () => startPlanAuthor("draft"); +$("plan-suggest").onclick = () => startPlanAuthor("suggest"); $("issues-refresh").onclick = () => loadIssues(); $("issues-list").onclick = (event) => { const button = event.target.closest("button.issue-trust"); diff --git a/web/public/index.html b/web/public/index.html index 6deb37cd..9e366630 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -79,15 +79,31 @@

Loading plan…

-