Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/implementation/read-only-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 16 additions & 0 deletions test/browser/review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
2 changes: 1 addition & 1 deletion web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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;
Expand Down
Loading