diff --git a/BUILD-LOG.md b/BUILD-LOG.md index f2281ae55..7c207baca 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6291,3 +6291,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that 2026-11-06 | #797 MVP3 project archive r5 — WebUI implementation | Before: r4 head 37f38d8e1 exposed unguarded request and session writes when archive lookup failed, and mapped guarded attach's project-archived refusal to 503 outcome-unknown. After: the unknown-state thread keeps reads and guarded writes available with a warning, suppresses unguarded controls and handlers including library revert, and reports guarded attach as 409/refused; live and archived recovery are tested. Node 26: final WebUI 649 pass / 0 fail / 0 cancelled / 0 skipped; exact-r4 negative 5 behavioral failures, real authenticated archived-origin attach 409. An initial diagnostic full run had 647 pass / 2 fail while source was changing (session catalog recovery and known-archived clear cancellation); both pass on exact r4 and after correction. All iterations, comparisons, inventory and commands: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md and project-archive-r5-evidence/. Bus/harness/CLI not run for the WebUI-only change. Queue refused this linked worktree; no queue move, push, merge or deployment. Local implementation candidate for independent review. 2026-11-06 | #797 MVP3 project archive r6 — WebUI implementation | Before: r5 head 8c3f48113 exposed server-unguarded request, accepted-clear cancel, library Revert and project settings Save controls on a known-archived project. After: known archived state suppresses those controls and fences stale handlers while preserving readable history, settings, Unarchive and the guarded attach picker. Node 26 frozen WebUI 654 pass / 0 fail / 0 cancelled / 0 skipped; five new tests plus the changed navigation assertion fail on exact r5 production for expected behavior; real authenticated Store/Broker probes verify guarded send, resume, clear and attach refusals. The first whole-suite run was diagnostic because the final disposed-form guard was added during it; it also reported 654/0/0/0. Detailed inventory, iterations, commands and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r6-report.md and project-archive-r6-evidence/. Bus/harness/CLI suites not run for WebUI-only change. Queue refused this linked worktree; no queue move, push, merge, deployment or server guard change. Local candidate for independent review. + +2026-11-06 | #797 MVP3 project archive r7 — WebUI implementation | Before: r6 head 51892cf95 lost known archived state during failed refresh, reopening repository Save and room Open and exposing guarded thread controls. After: settings, thread and room derive archive state from the displayed metadata through pending and failed reads; successful live reads restore controls, and refusal clearing remains intact. Node 26 final WebUI 658 pass / 0 fail / 0 cancelled / 0 skipped; four browser regressions fail on exact r6 for expected behavior. An earlier diagnostic whole suite had one 1440px remote-fixture timeout; candidate and r6 isolated reruns each passed 4/4, and the final whole suite passed that case. Room Send remains disabled on failed membership reads as before. Full iterations and comparison: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r7-report.md and project-archive-r7-evidence/. Bus/harness/CLI not run. Queue refused this linked worktree; no push, merge, deployment or server guard change. Local implementation candidate for independent review. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 10b0c2339..b008378ec 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2093,3 +2093,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-06 | Codex implementation sub-agent | #797 MVP3 project archive r4 | Applied reviewed merge 6a655a5cb and two WebUI fixes; Node 26 WebUI 644/644, bus without socket file 1642/1642, harness 277/277, CLI 1198 pass / 1 skip. Socket timeout reproduced on 5255e4e; original failures and comparisons in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md. Local candidate for independent delta review; no push or deployment. 2026-11-06 | Codex implementation sub-agent | #797 project archive r5 WebUI fixes on 37f38d8e1 | Unknown archive state suppresses unguarded writes, guarded attach maps to 409; final Node 26 WebUI 649/0/0/0, five exact-baseline behavioral negatives, real authenticated attach refusal. Initial diagnostic regressions corrected; inventory, all evidence and limits in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment. 2026-11-06 | Codex implementation sub-agent | #797 project archive r6 WebUI fixes on 8c3f48113 | Known-archived unguarded writes suppressed in controls and handlers; final Node 26 WebUI 654/0/0/0, five new tests and changed navigation assertion fail on baseline as expected, real Store/Broker guarded-refusal probes 4/4. Full inventory, diagnostic iterations and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r6-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment. +2026-11-06 | Codex implementation sub-agent | #797 project archive r7 WebUI correction on 51892cf95 | Retained archived state fences settings, thread and room writes across failed refresh; final Node 26 WebUI 658/0/0/0, four exact-baseline behavioral negatives, 1440px diagnostic timeout and candidate/base isolated reruns documented. Room Send still requires a successful membership read. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r7-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment. diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 6c2042aa7..841e8b911 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -292,8 +292,10 @@ controller?.abort(); controller = new AbortController(); const signal = controller.signal, g = ++generation; const match = /^\/chat\/rooms\/([0-9a-f-]+)$/i.exec(path), id = match?.[1]; + const old = current; current = { kind: 'room', id, key: 'room:' + id, path, container, canSend, user, tester, - projects: [], threads: [], members: [], roomReady: false, channelDisabled: current?.channelDisabled ?? false, refused: false, error: '' }; + projects: old?.kind === 'room' && old.id === id ? old.projects : [], threads: [], members: [], roomReady: false, channelDisabled: old?.channelDisabled ?? false, refused: false, error: '' }; + current.projectArchived = current.projects.some(p => p.id === id && p.archived); if (!UUID.test(id ?? '') || !roomOwner()) { current.error = UUID.test(id ?? '') ? 'Rooms require the business owner.' : 'Invalid room route.'; current.refused = true; paint(navigated); return; @@ -311,9 +313,10 @@ if (resolved) projects.rows.push(resolved); } if (!projects.rows.some(p => p.id === id)) throw new ChatError('project-not-found', 404); - current.projectArchived = projects.rows.some(p => p.id === id && p.archived === true); + current.projects = projects.rows; + current.projectArchived = current.projects.some(p => p.id === id && p.archived); if (!catalog.rows.every(m => typeof m?.instance === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,159}$/.test(m.instance) && typeof m.name === 'string')) throw new ChatError('invalid-response'); - current.projects = projects.rows; current.threads = threads.rows; + current.threads = threads.rows; current.members = catalog.rows.filter(m => threads.rows.some(t => t.project === id && t.instance === m.instance && t.name === m.instance)); const after = s.loaded && (!s.more || next) ? s.after : null; const page = await request(`rooms/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal); @@ -331,7 +334,8 @@ current.error = roomError(err); current.readFailed = true; if (err.code === 'channel-disabled') current.channelDisabled = true; - if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; } + if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; current.projects = []; } + current.projectArchived = current.projects.some(p => p.id === id && p.archived); } if (g === generation && !out()) { paint(navigated); void loadLibrary(libraryOf(current), g, signal); } } @@ -482,6 +486,8 @@ } const old = current; current = { container, kind, id, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id }; + current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived); + if (current.projectArchived) current.archiveUnknown = false; if (navigated || !old) container.innerHTML = '
Reading chat…
'; else if (current.archiveUnknown) { // Keep the previous conversation in place while its archive lookup is pending, @@ -537,7 +543,7 @@ } } else if (projectId) current.archiveUnknown = false; - current.projectArchived = current.projects.some(p => p.id === projectId && p.archived === true); + current.projectArchived = current.projects.some(p => p.id === projectId && p.archived); const catalog = kind === 'thread' && !current.shared ? await request(`threads/${id}/sessions`, undefined, signal).catch(err => { if (err.code === 'access-refused' && !match[3]) return null; throw err; @@ -655,6 +661,8 @@ if (!err.sharedRead && (err.status === 403 || err.status === 404)) { current.projects = []; current.threads = []; current.thread = null; current.shared = false; current.refused = true; } + current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived); + if (current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project))) current.archiveUnknown = false; paint(navigated); } } diff --git a/packages/webui/tests/archive-refresh-browser.test.mjs b/packages/webui/tests/archive-refresh-browser.test.mjs new file mode 100644 index 000000000..3d2b5f434 --- /dev/null +++ b/packages/webui/tests/archive-refresh-browser.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setup, state, REQUEST, THREAD, question, wait, draft, candidate } from './helpers/w15-requests.mjs'; + +const archived = { id: THREAD, name: 'Archived', repo: 'library', archived: true }; +const live = { id: THREAD, name: 'Live', repo: 'library' }; +const active = state({ question, candidate, snapshot: 'snapshot-1', wait: wait('question', 'question', 'q-1') }); +const material = { ...draft, snapshot: { id: 'snapshot-1', revision: 2 } }; +const url = 'https://git.example/owner/draft'; +const until = (b, expression) => b.evaluate(`(async()=>{const end=Date.now()+10000;while(!(${expression})){if(Date.now()>end)throw Error('timed out: '+${JSON.stringify(expression)});await new Promise(r=>setTimeout(r,25))}return true})()`); +const refresh = (b, path) => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false}).then(()=>true)`); + +async function boot(t) { + const x = await setup(t, { states: { [REQUEST]: active }, draft: material, shares: [{ to: 'user:reader' }] }); + await x.b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(()=>{ + window.__trace=[]; window.__failProjects=false; window.__pauseProjects=false; window.__liveProjects=[${JSON.stringify(live)}]; + const original=window.fetch; + window.fetch=async(url,init={})=>{ + const path=String(url),method=(init.method||'GET').toUpperCase();window.__trace.push({path,method}); + if(path==='/api/chat/projects' && window.__pauseProjects) await new Promise(resolve=>window.__releaseProjects=resolve); + if(path==='/api/chat/projects' && window.__failProjects) return new Response(JSON.stringify({error:'unavailable'}),{status:window.__failProjects===true?503:window.__failProjects}); + if(path==='/api/config'){const r=await original(url,init),v=await r.json();return new Response(JSON.stringify({...v,projectRemotes:true,projectRemoteWrites:true}));} + if(path==='/api/project-remotes/library')return new Response(JSON.stringify({project:'library',remote:null,revision:'${'a'.repeat(64)}',firstSaveAllowed:true})); + if(path==='/api/chat/rooms/${THREAD}/messages?limit=100')return new Response(JSON.stringify({rows:[],after:null,hasMore:false})); + if(path==='/api/chat/threads?project=${THREAD}') { const r=await original(url,init),data=await r.json();data.rows=data.rows.map(row=>({...row,name:'assistant'}));return new Response(JSON.stringify(data)); } + if(method==='GET' && path.includes('/api/chat/threads/') && path.includes('/messages')) { + const r=await original(url,init),data=await r.json();data.rows.push({id:'70000000-0000-4000-8000-000000000007',from_role:'assistant',at:'2026-11-05T12:00:00Z',body:JSON.stringify({notice:'library-write',op:'write',path:'notes.md',commit:'${'a'.repeat(40)}'})});return new Response(JSON.stringify(data)); + } + if(path.startsWith('/api/library/status'))return new Response(JSON.stringify({served:'${'b'.repeat(40)}',problems:[]})); + return original(url,init); + };window.confirm=()=>true; + })()` }); + await x.b.call('Page.addScriptToEvaluateOnNewDocument', { source: `sessionStorage.setItem('mosaic-request-answer:${REQUEST}',JSON.stringify({op:'answer-retained',text:'answer',question:'q-1',revision:7}));` }); + await x.navigate(1000); + return x; +} +async function setArchived(b) { + await b.evaluate(`window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];true`); +} +async function setLive(b) { + await b.evaluate(`window.__failProjects=false;window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];true`); +} +async function noWrites(b) { + assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []); +} +async function force(b, markup, selector, submit = false) { + await b.evaluate(`(()=>{const root=document.querySelector('.request-panel')||document.querySelector('.chat-area');root.insertAdjacentHTML('beforeend',${JSON.stringify(markup)});const e=root.querySelector(${JSON.stringify(selector)});${submit ? "e.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}))" : 'e.click()'};return true})()`); +} + +test('failed settings refresh keeps archived save fenced and live recovery preserves draft', async t => { + const { b } = await boot(t), path = `/projects/${THREAD}/settings`; + await b.evaluate(`location.hash=${JSON.stringify('#' + path)};true`); + await until(b, `document.querySelector('#project-remote-url')`); + await b.evaluate(`window.__oldForm=document.querySelector('.project-remote form');const e=document.querySelector('#project-remote-url');e.value=${JSON.stringify(url)};e.dispatchEvent(new Event('input',{bubbles:true}));true`); + await setArchived(b); await refresh(b, path); + await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive'`); + await b.evaluate(`window.__failProjects=true;window.__pauseProjects=true;window.__trace=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); + await until(b, `window.__releaseProjects`); + await b.evaluate(`window.__oldForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));true`); + await noWrites(b); + await b.evaluate(`window.__pauseProjects=false;window.__releaseProjects();window.__refresh.then(()=>true)`); + assert.equal(await b.evaluate(`document.querySelector('#project-archive-button')?.textContent`), 'Unarchive'); + assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /archived/i); + assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false, 'archived settings must not offer Save'); + await b.evaluate(`(()=>{window.__oldForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));document.querySelector('.project-remote').append(window.__oldForm);const e=window.__oldForm.querySelector('input');e.disabled=false;window.__oldForm.querySelector('button').disabled=false;e.focus();return true})()`); + await b.key('Enter'); + await b.evaluate(`window.__oldForm.requestSubmit();window.__oldForm.remove();true`); + await noWrites(b); + await setLive(b); await refresh(b, path); await until(b, `document.querySelector('#project-remote-url')`); + assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), url); + assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), false); + await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); + assert.equal(await b.evaluate(`document.querySelector('#project-archive-button')?.textContent`), 'Archive project'); + assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), false); + await b.evaluate(`window.__failProjects=403;true`); await refresh(b, path); + assert.equal(await b.evaluate(`!!document.querySelector('#project-archive-button, .project-remote form')`), false); +}); + +test('settings write guard agrees with displayed archived metadata', async t => { + const { b } = await boot(t), path = `/projects/${THREAD}/settings`; + await b.evaluate(`window.__liveProjects=[${JSON.stringify({ ...archived, archived: 'yes' })}];location.hash=${JSON.stringify('#' + path)};true`); + await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive' && document.querySelector('.project-remote')`); + assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false); + await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); + assert.equal(await b.evaluate(`document.querySelector('#project-archive-button')?.textContent`), 'Unarchive'); + assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false); +}); + +test('failed thread refresh keeps known archived writes fenced and live recovery restores them', async t => { + const { b } = await boot(t), path = `/projects/threads/${THREAD}`; + await setArchived(b); await refresh(b, path); + await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('.request-panel')`); + await b.evaluate(`window.__failProjects=true;window.__pauseProjects=true;window.__trace=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); + await until(b, `window.__releaseProjects`); + await b.evaluate(`(()=>{const f=document.querySelector('#chat-session-form');f.querySelector('input').disabled=false;f.querySelector('input').value='new';f.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));return true})()`); + await noWrites(b); + await b.evaluate(`window.__pauseProjects=false;window.__releaseProjects();window.__refresh.then(()=>true)`); + assert.match(await b.evaluate(`document.querySelector('.chat-area').textContent`), /This project is archived/); + for (const selector of ['#chat-send-form', '#chat-resume', '#chat-share-form', '[data-unshare]', '[data-revert]', '.request-answer', '[data-request-resend]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]']) assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), false, selector); + for (const selector of ['#chat-session-form button', '#chat-clear', '#chat-cancel']) assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector); + await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));const button=document.querySelector('#chat-cancel');button.disabled=false;button.click();const f=document.querySelector('#chat-session-form');f.querySelector('input').disabled=false;f.querySelector('input').value='new';f.querySelector('button').disabled=false;f.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));f.querySelector('input').focus();return true})()`); + await b.key('Enter'); + await force(b, ``, '[data-revert]'); + await force(b, '', '#chat-share-form', true); + await force(b, '', '[data-unshare]'); + await force(b, '', '.request-answer', true); + for (const [attr, value] of [['resend',''],['mode','autonomous'],['cancel',''],['cancel-confirm',''],['revoke','']]) await force(b, ``, `[data-request-${attr}]`); + await force(b, '', '.request-revoke-form', true); + await noWrites(b); + await setLive(b); await refresh(b, path); + await until(b, `document.querySelector('.request-answer') && document.querySelector('[data-revert]')`); + for (const selector of ['#chat-session-form button', '#chat-clear', '#chat-resume', '#chat-send-form button', '#chat-share-form button', '[data-unshare]', '[data-revert]', '.request-answer button', '[data-request-resend]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]']) assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector); + await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`); + assert.equal(await b.evaluate(`document.querySelector('#chat-cancel').disabled`), false); + await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); + assert.equal(await b.evaluate(`!document.querySelector('#chat-session-form button').disabled`), true); + await b.evaluate(`window.__failProjects=404;true`); await refresh(b, path); + assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, #chat-send-form')`), false); +}); + +test('failed archived room refresh never offers open or send and live recovery restores room', async t => { + const { b } = await boot(t), path = `/projects/rooms/${THREAD}`; + await setArchived(b); await b.evaluate(`location.hash=${JSON.stringify('#' + path)};true`); + await until(b, `document.querySelector('.chat-room')?.textContent.includes('This project is archived')`); + await b.evaluate(`window.__failProjects=true;window.__pauseProjects=true;window.__trace=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); + await until(b, `window.__releaseProjects`); + await force(b, '', '#chat-room-open'); + await noWrites(b); + await b.evaluate(`window.__pauseProjects=false;window.__releaseProjects();window.__refresh.then(()=>true)`); + assert.match(await b.evaluate(`document.querySelector('.chat-room').textContent`), /This project is archived/); + assert.equal(await b.evaluate(`!!document.querySelector('#chat-room-open, #chat-room-send-form')`), false); + await force(b, '', '#chat-room-open'); + await force(b, '', '#chat-room-send-form', true); + await b.evaluate(`document.querySelector('#chat-room-send-form input').focus();true`); await b.key('Enter'); + await noWrites(b); + await setLive(b); await refresh(b, path); + assert.equal(await b.evaluate(`!!document.querySelector('#chat-room-open')`), true); + assert.equal(await b.evaluate(`!document.querySelector('#chat-room-send-form button[type=submit]').disabled`), true); + await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); + assert.equal(await b.evaluate(`!!document.querySelector('#chat-room-open')`), true); + await b.evaluate(`window.__failProjects=false;true`); await setArchived(b); await refresh(b, path); + await b.evaluate(`window.__failProjects=404;true`); await refresh(b, path); + assert.doesNotMatch(await b.evaluate(`document.querySelector('.chat-room').textContent`), /This project is archived/); +});