diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 4af7892f6..f2281ae55 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6289,3 +6289,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that 2026-11-06 | #797 MVP3 project archive r4 — integration and author verification | Before: r3 f23a998ee and integration 5255e4e74 conflicted; the reviewed resolution tree reproduced WebUI 625 pass / 15 fail. After: exact reviewed merge 6a655a5cb, archive route fix and readable unknown archive state with real broker refusal evidence. Node 26 final WebUI 644/644, bus excluding project-files 1642/1642, harness 277/277, CLI whole-file partitions 1198 pass / 1 FED R7 skip. Candidate and integration parent project-files each timed out after five passes with three socket cases unfinished; this is not a green whole-bus gate. Earlier focus and Claude row 66 failures, clean reruns, comparisons and exact commands are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md and project-archive-r4-evidence/. Local commits only; independent delta review remains, no push or deployment. 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. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index afa6a73d6..10b0c2339 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2092,3 +2092,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-06 | mvp3-archive-r3-implementer (#797 lane) | feat/mvp3-project-archive-r3 from 1c33c342b: fix final launch admission and preserve originating chat through bootstrap, PRD and task admissions; shared-repository note and regressions | Node 26: regressions 6/6, bus excluding socket 1614/1614, WebUI 583/583, corrected whole release file 226/226; socket timeout and known adapter-127 failure remain ungreen, with all intermediate failures retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. Local commit only; limited independent review pending; no push, merge or deployment. 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. diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 1bc104e21..6c2042aa7 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -109,7 +109,7 @@ const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id); const clear = form('clear:' + c.key); const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : ''; - return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; + return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; } // A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not // its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing. @@ -125,7 +125,7 @@ if (r?.kind === 'reply') return `
${txt(r.text)}
${r.citations.length ? cites(r.citations) : ''}`; if (r?.kind === 'notice') { const v = reverts.get(r.commit) ?? {}; - return `
${txt(r.text)} ${current.archiveUnknown ? '' : ``} ${txt(v.receipt ?? '')}
`; + return `
${txt(r.text)} ${current.archiveUnknown || current.projectArchived ? '' : ``} ${txt(v.receipt ?? '')}
`; } return `
${txt(m.body)}
`; } @@ -177,7 +177,7 @@ async function revert(commit) { const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {}; const project = libraryOf(c, t); - if (!c || c.archiveUnknown || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return; + if (!c || c.archiveUnknown || c.projectArchived || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return; if (!window.confirm(`Revert ${commit.slice(0, 7)}? This makes a new commit in the library that puts the file back as it was before it.`)) return; const unknown = 'Outcome unknown. Read the library status before trying again; nothing was retried.'; reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint(); @@ -420,7 +420,7 @@ } if (!c.canSend) html += '

Creating and sending require signed-in send mode. This Console only reads.

'; c.container.innerHTML = html + ''; - window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown }); + window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived }); const t = c.kind === 'thread' ? c.thread : null; const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t); const cached = knownRequests.get(c.id); @@ -436,7 +436,7 @@ } const remote = c.container.querySelector('[data-chat-remote]'); const project = c.projects.find(p => p.id === c.id); - if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, preserve: true }); } + if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); } window.MosaicProjectArchive?.mount(c.container, c); document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; const details = c.container.querySelector('.chat-details'); @@ -667,7 +667,7 @@ document.addEventListener('input', event => { if (!current || out()) return; if (event.target.id === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); return; } - if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || f.pending || !f.op; } + if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || current.projectArchived || f.pending || !f.op; } if (event.target.id === 'chat-share-target') form('share:' + current.id).to = event.target.value; if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value; const f = event.target.closest('.chat-create'); @@ -822,7 +822,7 @@ } async function cancelClear() { const c = current, key = c.key, f = form('clear:' + key); - if (!c.canSend || !threadAccess() || c.archiveUnknown || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return; + if (!c.canSend || !threadAccess() || c.archiveUnknown || c.projectArchived || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return; if (!window.confirm(`Cancel clear operation ${f.op}? Before retirement this restores the old session to active. After retirement it only cancels successor startup. Cancelled messages are never replayed, and nothing is un-retired.`)) return; const op = f.op; f.pending = true; paint(); diff --git a/packages/webui/src/public/project-remote.js b/packages/webui/src/public/project-remote.js index 03e566106..7b82ced66 100644 --- a/packages/webui/src/public/project-remote.js +++ b/packages/webui/src/public/project-remote.js @@ -7,18 +7,18 @@ document.addEventListener('mosaic-project-context', event => { if (active && event.detail?.projectId !== active.projectId) { active.dispose(); active = null; } }); - function mount({ container, projectId, repositoryKey, preserve = false }) { + function mount({ container, projectId, repositoryKey, projectArchived = false, preserve = false }) { if (!(container instanceof Element)) return; if (preserve && active?.projectId === projectId && active.repositoryKey === repositoryKey) { - container.replaceChildren(active.section); return; + active.setArchived(projectArchived); container.replaceChildren(active.section); return; } if (active) { active.dispose(); active = null; } - let generation = 0, data = null, pending = false, draft = '', error = '', stale = false, canWrite = false; + let archived = projectArchived, disposed = false, generation = 0, data = null, pending = false, draft = '', error = '', stale = false, canWrite = false; const section = document.createElement('section'); section.className = 'project-remote'; container.replaceChildren(section); - const dispose = () => { generation++; draft = ''; data = null; section.replaceChildren(); }; - if (preserve) active = { projectId, repositoryKey, section, dispose }; + const dispose = () => { disposed = true; generation++; draft = ''; data = null; section.replaceChildren(); }; + if (preserve) active = { projectId, repositoryKey, section, dispose, setArchived(value) { if (archived !== value) { archived = value; paint(); } } }; else document.addEventListener('mosaic-signed-out', dispose, { once: true }); const element = (tag, content, attrs = {}) => { const node = document.createElement(tag); @@ -40,6 +40,9 @@ section.append(element('p', data.remote)); return; } section.append(element('p', 'Saving sets this repository URL only. Saving does not publish.')); + if (archived) { + section.append(element('p', 'This project is archived. Repository changes are unavailable.', { role: 'status' })); return; + } if (!canWrite || !data.firstSaveAllowed) { section.append(element('p', 'This setting is read-only.')); return; } @@ -58,7 +61,7 @@ } form.addEventListener('submit', async event => { event.preventDefault(); - if (pending || stale || signedOut) return; + if (pending || stale || signedOut || archived || disposed) return; let parsed; try { parsed = new URL(input.value); } catch { /* invalid input */ } if (parsed?.username || parsed?.password) { draft = ''; input.value = ''; error = 'Repository URL must not contain credentials.'; paint(); section.querySelector('input')?.focus(); return; } diff --git a/packages/webui/src/public/requests.js b/packages/webui/src/public/requests.js index ad3800384..9e6e06a7e 100644 --- a/packages/webui/src/public/requests.js +++ b/packages/webui/src/public/requests.js @@ -99,7 +99,7 @@ let p = panels.get(id); if (!p) { const stored = load(id); - p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, archiveUnknown: false, at: 0, observedAt: null, reading: null, stored, sig: '', + p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, archiveUnknown: false, projectArchived: false, at: 0, observedAt: null, reading: null, stored, sig: '', ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } }; panels.set(id, p); } @@ -180,7 +180,7 @@ function html(p) { const s = p.state, ui = p.ui; if (!s) return p.failed ? `

This request could not be read.

` : ''; - const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale && !p.archiveUnknown, canAttach = p.writes && !p.stale; + const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale && !p.archiveUnknown && !p.projectArchived, canAttach = p.writes && !p.stale; const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null; const returnHash = /^#\/chat\/threads\//.test(location.hash) || /^#\/projects\/threads\//.test(location.hash) ? location.hash : ''; let body = `${returnHash ? `Return to conversation` : ''}

Request Phase: ${txt(s.phase)}${s.mode ? ` Mode: ${txt(s.mode)}` : ''}

`; @@ -210,7 +210,7 @@ ? `

The project manager asks:

${txt(q.text)}

` : `

Answer received: ${txt(q.text)}. Application is not established by this receipt.

`; } - if (!can) body += p.archiveUnknown ? '

Archive status unavailable. Some changes are unavailable.

' : p.stale ? '

Writes are unavailable until the request is read again.

' : '

Read-only: this Console cannot answer or change requests.

'; + if (!can) body += p.projectArchived ? '

This project is archived. Some changes are unavailable.

' : p.archiveUnknown ? '

Archive status unavailable. Some changes are unavailable.

' : p.stale ? '

Writes are unavailable until the request is read again.

' : '

Read-only: this Console cannot answer or change requests.

'; if (can && ui.confirm) { const c = ui.confirm; body += `

${txt(c.question)}

Your choice: ${txt(c.text)}

@@ -277,7 +277,7 @@ (focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true }); } } - const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, Boolean(p.reading)]); + const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, p.projectArchived, Boolean(p.reading)]); // The reads that follow a state: the mode decision the selection waits on and the project picker. async function side(p) { @@ -357,7 +357,7 @@ async function answer(p, again = false) { const s = p.state, ui = p.ui; - if (!p.writes || p.archiveUnknown || ui.pending || !s || !openQuestion(s)) return; + if (!p.writes || p.archiveUnknown || p.projectArchived || ui.pending || !s || !openQuestion(s)) return; let rec; if (again) { rec = p.stored; @@ -386,21 +386,21 @@ } async function mode(p, next) { const s = p.state; - if (!p.writes || p.archiveUnknown || p.ui.pending || !s || !MODES.includes(next) || next === s.mode || !MODE_PHASES.includes(s.phase) || !positive(s.mode_revision)) return; + if (!p.writes || p.archiveUnknown || p.projectArchived || p.ui.pending || !s || !MODES.includes(next) || next === s.mode || !MODE_PHASES.includes(s.phase) || !positive(s.mode_revision)) return; p.ui.receipt = ''; settle(p, await send(p, 'mode', { revision: s.revision, mode_revision: s.mode_revision, mode: next, op: opId('mode') }), `Mode is now ${next}. A PRD approval waiting in the old mode is withdrawn and the request returns to discovery.`, 'Mode not changed'); } async function cancel(p) { const s = p.state; - if (!p.writes || p.archiveUnknown || p.ui.pending || !s || ENDED.includes(s.phase)) return; + if (!p.writes || p.archiveUnknown || p.projectArchived || p.ui.pending || !s || ENDED.includes(s.phase)) return; p.ui.receipt = ''; p.ui.cancelAsk = false; settle(p, await send(p, 'cancel', { revision: s.revision, op: opId('cancel') }), 'Request cancelled. Any project already created is kept.', 'Not cancelled'); } async function revoke(p) { const s = p.state, m = p.material, ui = p.ui; - if (!p.writes || p.archiveUnknown || p.stale || p.reading || ui.pending || !ui.revokeAsk || !s || !m?.snapshot.id || !isMaterial(m, s)) return; + if (!p.writes || p.archiveUnknown || p.projectArchived || p.stale || p.reading || ui.pending || !ui.revokeAsk || !s || !m?.snapshot.id || !isMaterial(m, s)) return; if (!ui.reason.trim() || ui.reason.length > 1024 || ui.reason.includes('\0')) { ui.receipt = 'Write a reason of 1–1024 characters before revoking.'; draw(p); return; } ui.receipt = ''; const r = await send(p, 'revoke', { project: s.project, snapshot: m.snapshot.id, revision: m.snapshot.revision, reason: ui.reason, op: opId('revoke') }); @@ -422,7 +422,7 @@ const notAnswered = r => `Not answered here: ${r.message} (${r.code}).${r.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}`; async function choose(p, key) { const row = p.inbox, ui = p.ui; - if (!p.writes || p.archiveUnknown || p.stale || p.reading || ui.pending || !row || !row.console.options.some(o => o.key === key)) return; + if (!p.writes || p.archiveUnknown || p.projectArchived || p.stale || p.reading || ui.pending || !row || !row.console.options.some(o => o.key === key)) return; const revision = p.state.revision, candidate = prdDecision(p.state) ? p.material?.candidate : null; if (prdDecision(p.state) && !candidate) return; ui.pending = 'choice'; ui.receipt = ''; ui.confirm = null; draw(p); @@ -440,7 +440,7 @@ } async function confirm(p) { const c = p.ui.confirm, ui = p.ui; - if (!p.writes || p.archiveUnknown || p.stale || p.reading || ui.pending || !c || c.revision !== p.state?.revision || + if (!p.writes || p.archiveUnknown || p.projectArchived || p.stale || p.reading || ui.pending || !c || c.revision !== p.state?.revision || c.candidate && (!same(c.candidate, p.material?.candidate) || p.state.wait?.subject !== c.id)) return; ui.pending = 'confirm'; ui.receipt = ''; draw(p); const { res, json } = await post(`/api/decisions/${enc(c.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation }); @@ -470,9 +470,9 @@ if (o) { p.ui.draft = o.text; draw(p); el.querySelector('textarea')?.focus({ preventScroll: true }); } } else if ('requestResend' in d) void answer(p, true); else if ('requestMode' in d) void mode(p, d.requestMode); - else if ('requestRevoke' in d) { p.ui.revokeAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('.request-revoke textarea')?.focus({ preventScroll: true }); } + else if ('requestRevoke' in d && !p.archiveUnknown && !p.projectArchived) { p.ui.revokeAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('.request-revoke textarea')?.focus({ preventScroll: true }); } else if ('requestRevokeBack' in d) { p.ui.revokeAsk = false; draw(p); } - else if ('requestCancel' in d) { p.ui.cancelAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('[data-request-cancel-keep]')?.focus({ preventScroll: true }); } + else if ('requestCancel' in d && !p.archiveUnknown && !p.projectArchived) { p.ui.cancelAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('[data-request-cancel-keep]')?.focus({ preventScroll: true }); } else if ('requestCancelKeep' in d) { p.ui.cancelAsk = false; draw(p); } else if ('requestCancelConfirm' in d) void cancel(p); }); @@ -509,7 +509,7 @@ // Called by chat.js after each paint with the chat container. A panel draws from what it already // knows at once, so a repaint never blanks it, and reads again only when its state is stale. - function mount(container, { archiveUnknown = false } = {}) { + function mount(container, { archiveUnknown = false, projectArchived = false } = {}) { if (out() || !container) return; const mounts = [...container.querySelectorAll('.request-mount[data-request]')].filter(el => ID.test(el.dataset.request)); if (!mounts.length) return; @@ -520,6 +520,7 @@ const p = panel(el.dataset.request); p.writes = cfg.writes; p.archiveUnknown = archiveUnknown; + p.projectArchived = projectArchived; if (p.state || p.failed) draw(p); if (!p.reading && Date.now() - p.at > STALE) void read(p); } diff --git a/packages/webui/tests/archive-known-browser.test.mjs b/packages/webui/tests/archive-known-browser.test.mjs new file mode 100644 index 000000000..f72b4a39a --- /dev/null +++ b/packages/webui/tests/archive-known-browser.test.mjs @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setup, state, REQUEST, THREAD, DECISION, 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 inbox = [{ id: DECISION, question: 'Choose', console: { kind: 'decision', options: [{ key: 'yes', text: 'Approve exact PRD' }] } }]; +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})()`); + +async function instrument(b) { + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(()=>{ + window.__trace=[]; const original=window.fetch; + window.fetch=async(url,init={})=>{const path=String(url),method=(init.method||'GET').toUpperCase();window.__trace.push({path,method}); + 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; + })()` }); +} +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('known archived conversation suppresses unguarded controls and rejects forced events; live restores them', async t => { + const { b, navigate } = await setup(t, { states: { [REQUEST]: active }, draft: material, shares: [{ to: 'user:reader' }], archiveRows: [archived] }); + await instrument(b); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `sessionStorage.setItem('mosaic-request-answer:${REQUEST}',JSON.stringify({op:'answer-retained',text:'answer',question:'q-1',revision:7}));` }); + await navigate(1000); + await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('.chat-log').textContent.includes('wrote notes.md')`); + assert.match(await b.evaluate(`document.querySelector('.chat-conversation').textContent`), /Build a countdown timer/); + assert.match(await b.evaluate(`document.querySelector('.request-panel').textContent`), /Which platform/); + 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}));return true})()`); + assert.equal(await b.evaluate(`document.querySelector('#chat-cancel').disabled`), true); + await b.evaluate(`(()=>{const e=document.querySelector('#chat-cancel');e.disabled=false;e.click();const f=document.querySelector('#chat-session-form');f.querySelector('input').value='new';f.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));return true})()`); + await force(b, ``, '[data-revert]'); + 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); + assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []); + await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`); + await until(b, `document.querySelector('.request-answer') && document.querySelector('[data-revert]') && document.querySelector('[data-request-resend]')`); + for (const selector of ['#chat-session-form button', '#chat-share-form button', '[data-unshare]', '[data-revert]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-request-resend]']) 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); +}); + +test('known archived request rejects stale decision confirmation and restores it on live lookup', async t => { + const selection = state({ project: null, phase: 'selection', selection: { decision: DECISION }, wait: wait('decision', 'request-mode', DECISION) }); + const { b, navigate } = await setup(t, { states: { [REQUEST]: selection }, inbox, allowConfirmation: true }); + await instrument(b); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__liveProjects=[${JSON.stringify(live)}];` }); + await navigate(1000); + await until(b, `document.querySelector('[data-request-choice=yes]')`); + await b.evaluate(`document.querySelector('[data-request-choice=yes]').click();true`); + await until(b, `document.querySelector('[data-request-confirm]')`); + await b.evaluate(`window.__trace=[];window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`); + await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived')`); + assert.equal(await b.evaluate(`!!document.querySelector('[data-request-choice], [data-request-confirm]')`), false); + await force(b, '', '[data-request-choice]'); + await force(b, '', '[data-request-confirm]'); + assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []); + await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`); + await until(b, `document.querySelector('[data-request-confirm]')`); + assert.equal(await b.evaluate(`document.querySelector('[data-request-confirm]').disabled`), false); +}); + +test('known archived settings remain readable and reject a retained repository save form', async t => { + const { b, navigate } = await setup(t, { states: { [REQUEST]: active }, archiveRows: [archived] }); + await instrument(b); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(()=>{const original=window.fetch;window.fetch=async(url,init={})=>{const path=String(url);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'){window.__trace.push({path,method:init.method||'GET'});return new Response(JSON.stringify({project:'library',remote:null,revision:'${'a'.repeat(64)}',firstSaveAllowed:true}));}return original(url,init)};})()` }); + await navigate(1000); + await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];location.hash='#/projects/${THREAD}/settings';true`); + await until(b, `document.querySelector('#project-remote-url')`); + await b.evaluate(`window.__oldRemoteForm=document.querySelector('.project-remote form');const e=document.querySelector('#project-remote-url');e.value='https://git.example/owner/repo';e.dispatchEvent(new Event('input',{bubbles:true}));window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];window.MosaicChat.render({path:'/projects/${THREAD}/settings',container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); + await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive'`); + assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /Repository URL/); + assert.match(await b.evaluate(`document.querySelector('.chat-area').textContent`), /archived/i); + assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false); + await b.evaluate(`window.__trace=[];window.__oldRemoteForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));true`); + assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []); + await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.MosaicChat.render({path:'/projects/${THREAD}/settings',container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); + await until(b, `document.querySelector('#project-remote-url')`); + assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), false); + await b.evaluate(`(()=>{window.__oldRemoteForm=document.querySelector('.project-remote form');const e=document.querySelector('#project-remote-url');e.value='https://git.example/owner/repo';e.dispatchEvent(new Event('input',{bubbles:true}));window.__liveProjects=[];window.__archiveRows=[${JSON.stringify({ ...archived, repo: 'other' })}];window.MosaicChat.render({path:'/projects/${THREAD}/settings',container:document.querySelector('#bus-view'),canSend:true,navigated:false});return true})()`); + await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive' && document.querySelector('.chat-area')?.textContent.includes('Repository: other')`); + await b.evaluate(`window.__trace=[];window.__oldRemoteForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));true`); + assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []); +}); + +test('archiving blocks valid retained cancel and revoke confirmations until live again', async t => { + const { b, navigate } = await setup(t, { states: { [REQUEST]: active }, draft: material }); + await instrument(b); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__liveProjects=[${JSON.stringify(live)}];` }); + await navigate(1000); + await until(b, `document.querySelector('[data-request-revoke]') && document.querySelector('[data-request-cancel]')`); + await b.evaluate(`document.querySelector('[data-request-revoke]').click();const e=document.querySelector('.request-revoke textarea');e.value='valid reason';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('[data-request-cancel]').click();true`); + await until(b, `document.querySelector('.request-revoke-form') && document.querySelector('[data-request-cancel-confirm]')`); + await b.evaluate(`window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`); + await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('.request-panel')`); + await b.evaluate(`new Promise(resolve=>setTimeout(()=>resolve(true),50))`); + assert.equal(await b.evaluate(`!!document.querySelector('.request-revoke-form, [data-request-cancel-confirm]')`), false); + await b.evaluate(`window.__trace=[];true`); + await force(b, '', '[data-request-cancel-confirm]'); + await force(b, '
', '.request-revoke-form', true); + assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []); + await b.evaluate(`window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`); + await until(b, `document.querySelector('.request-revoke-form') && document.querySelector('[data-request-cancel-confirm]')`); + assert.equal(await b.evaluate(`document.querySelector('.request-revoke textarea').value`), 'valid reason'); +}); + +test('known archived origin retains guarded picker and shows a real refusal', async t => { + const { atPicker } = await import('./helpers/archive-origin.mjs'); + const { BusReadError } = await import('../src/bus.mjs'); + const { startServer } = await import('../src/serve.mjs'); + const { close } = await import('./fixture.mjs'); + const { createSsoSession } = await import('./helpers/sso-session.mjs'); + const { request } = await import('node:http'); + const x = await atPicker(t), project = x.store.get('SELECT project FROM threads WHERE id=?', x.state.thread).project; + await x.call('human', 'project.archive', { project }); + const auth = await createSsoSession(t, { host: 'chat.example.com' }); + const bus = async (verb, args) => { try { return await x.call('console', verb, args); } catch (e) { throw new BusReadError(e.code); } }; + const server = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), requestRead: bus, requestWrite: bus, log: () => {} }); + t.after(() => close(server)); + const headers = auth.writeHeaders(await auth.signIn(server.address().port)); + let response; + const forwardSend = req => new Promise((resolve, reject) => { + const out = request({ host: '127.0.0.1', port: server.address().port, path: new URL(req.url).pathname, method: 'POST', headers }, res => { + let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { response = { status: res.statusCode, body: JSON.parse(data) }; resolve(response); }); + }); out.on('error', reject); out.end(req.postData); + }); + const { b, navigate } = await setup(t, { request: x.source.request, thread: x.state.thread, project, states: { [x.source.request]: x.state }, projects: [{ project: 'old', name: 'Old' }], archiveRows: [{ id: project, name: 'Archived', repo: null, archived: true }], forwardSend: true }, { forwardSend }); + await navigate(1000); + await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('[data-request-project=old]')`); + assert.equal(await b.evaluate(`!!document.querySelector('[data-request-cancel]')`), false); + await b.evaluate(`document.querySelector('[data-request-project=old]').click();true`); + await until(b, `document.querySelector('.request-receipt')?.textContent.includes('Not picked')`); + assert.equal(response.status, 409); + assert.equal(response.body.error, 'project-archived'); + assert.match(await b.evaluate(`document.querySelector('.request-receipt').textContent`), /Not picked.*project-archived/); + assert.equal(x.journal.readRequest('demo', x.source.request).revision, x.state.revision); +}); diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs index 24699e538..b1071e5da 100644 --- a/packages/webui/tests/chat-browser.test.mjs +++ b/packages/webui/tests/chat-browser.test.mjs @@ -632,9 +632,7 @@ test('archive navigation exposes saved threads and unarchive with only the two a await set('#chat-operation', 'pre-archive'); await click('#chat-operation-form button'); await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("retired")'); - await b.evaluate(`window.__answers.push({body:{...window.__operation,state:'cancelled'}});true`); - await click('#chat-cancel'); - await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("cancelled")'); + assert.equal(await b.evaluate('document.querySelector("#chat-cancel").disabled'), true); await b.evaluate(`location.hash='#/chat/projects/${P}';true`); await wait(`document.querySelector('a[href="#/chat/rooms/${P}"]')`); assert.ok(await b.evaluate(`!!document.querySelector('a[href="#/chat/rooms/${P}"]')`));