diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md index 8d6d99d..1ed2937 100644 --- a/docs/implementation/read-only-review.md +++ b/docs/implementation/read-only-review.md @@ -120,3 +120,11 @@ The browser owns unsent text, mode, navigation, and attachments; the refreshed r The regression gate is `npm run test:browser -- --grep 'during refresh'`. It checks visible drafts and refreshed durable approval/snapshot/plan state, and confirms no draft was accidentally saved as a note. Issues #10 (review edge cases), #12 (polling efficiency), and #3 (human go/no-go experiment) remain separate work. PR #14 review round 1 found that retained-draft rows omitted the regular rows' `aria-current` state. A browser assertion reproduced the missing attribute. Retained rows now expose their selected state; the regression checks selection, navigation away, and selection again. No findings were declined. + +## Lightweight answer polling (#12) + +The answer polling endpoint previously called `ReviewService.load()` every two seconds while a question was pending. A browser regression measured one full review load for a single poll before the fix. Polling now reads persisted question attempts directly from SQLite and adds only the in-memory active marker. The endpoint returns each question's ID, answer state, and active state; it does not rebuild Git history, linkage, previews, or the review token. + +The displayed review remains the owner of note text, ordering, snapshot, plan revision, assignment, and reference-validity metadata. Poll responses update only `answer` and `answerActive` for IDs already displayed. The existing generation guard rejects responses started before an action or Refresh, and unknown note IDs are ignored. Explicit Refresh and mutating actions still call the full review service to validate current repository state. + +The focused browser regression observes a completed answer and its durable SQLite state with zero review loads, verifies that the status payload omits note text, then proves explicit Refresh still performs a full load. Existing regressions continue to cover a poll returning after a newer submission and cancellation remaining active after a persisted attempt fails. diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts index bb5539a..9f22042 100644 --- a/test/browser/review.spec.ts +++ b/test/browser/review.spec.ts @@ -226,6 +226,22 @@ test('ignores question polls started before a newer submission',async({page})=>{ await page.waitForTimeout(100); expect(await page.locator('#notes .note').count()).toBe(2); }); +test('polls persisted answer status without rebuilding the review',async({page})=>{ + const config=app.service.config;await app.close();let settle!:(answer:string)=>void; + app=await startServer(config,0,()=>new Promise(resolve=>settle=resolve)); + await page.goto(app.url);await page.getByLabel('Question about this item').fill('Why is this bounded?');await page.getByRole('button',{name:'Ask agent',exact:true}).click(); + await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible(); + const service=app.service,load=service.load.bind(service);let reviewLoads=0; + service.load=()=>{reviewLoads++;return load();}; + const polled=page.waitForResponse(response=>response.url().endsWith('/api/questions')); + settle('The cap bounds retry latency.'); + const statusResponse=await polled,statusBody=await statusResponse.json(); + await expect(page.getByText('The cap bounds retry latency.',{exact:true})).toBeVisible({timeout:10000}); + expect(reviewLoads).toBe(0); + expect(statusBody.notes.find((note:{id:string})=>note.id)).not.toHaveProperty('text'); + expect(service.store.getReviewNotes(config.identity).find(note=>note.text==='Why is this bounded?')?.answer?.status).toBe('complete'); + await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect.poll(()=>reviewLoads).toBeGreaterThan(0); +}); test('outdated questions explain how to continue without offering a broken retry',async({page})=>{ const service=app.service; service.act({action:'note',item:'P1',kind:'question',text:'Old question',token:service.load().token}); diff --git a/web/public/app.js b/web/public/app.js index 1dbb2c7..6e23fe9 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -616,7 +616,7 @@ setInterval(async()=>{ if(pollingQuestions || busy || !data || !data.notes.some(n=>n.answer?.status==="pending" || n.answerActive)) return; pollingQuestions=true; const generation = reviewGeneration; - try {const response=await api("/api/questions");if(data && generation === reviewGeneration){data.notes=response.notes.map(note=>note.answer?.status==="pending" && note.answer.expiresAt<=Date.now() && !note.answerActive ? {...note,answer:{...note.answer,status:"failed",error:"Agent was interrupted or timed out. Retry the question."}} : note);renderNotes({ follow: true });}} + try {const response=await api("/api/questions");if(data && generation === reviewGeneration){const statuses=new Map(response.notes.map(note=>[note.id,note]));data.notes=data.notes.map(note=>{const status=statuses.get(note.id);if(!status)return note;const answer=status.answer?.status==="pending" && status.answer.expiresAt<=Date.now() && !status.answerActive ? {...status.answer,status:"failed",error:"Agent was interrupted or timed out. Retry the question."} : status.answer;return {...note,answer,answerActive:status.answerActive};});renderNotes({ follow: true });}} catch { if (generation === reviewGeneration) $("saved").textContent="Could not refresh agent answers. Use Refresh to reconnect."; } finally {pollingQuestions=false;} },2000); diff --git a/web/server.ts b/web/server.ts index 4d5b2ee..7641c82 100644 --- a/web/server.ts +++ b/web/server.ts @@ -9,6 +9,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge const service = new ReviewService(config), token = randomBytes(32).toString('hex'); const questions=new Questions(service,questionAgent); const load=()=>{const view=service.load();return {...view,notes:view.notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))};}; + const answerStatuses=()=>service.store.getReviewNotes(config.identity) + .filter(note=>note.kind==='question') + .map(note=>({id:note.id,answer:note.answer,answerActive:questions.isRunning(note.id)})); const server = createServer(async (req, res) => { const address = server.address(); const actualPort = address && typeof address !== 'string' ? address.port : port; const origin = `http://127.0.0.1:${actualPort}`; @@ -22,7 +25,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge const supplied = req.headers['x-codeboost-token']; if (typeof supplied !== 'string' || !/^[a-f0-9]{64}$/.test(supplied) || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) { json(403, { error: 'Open the private local URL printed by the CLI.' }); return; } if (req.method === 'GET' && path === '/api/settings') { json(200,{questionProvider:service.store.questionProvider()});return; } - if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:load().notes});return; } + if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:answerStatuses()});return; } if (req.method === 'GET' && path === '/api/review') { json(200, load()); return; } if (req.method !== 'POST' || !['/api/action','/api/settings'].includes(path) || req.headers['content-type'] !== 'application/json') { json(405, { error: 'Unsupported request.' }); return; } const chunks: Buffer[] = []; let size = 0;