fix(webui): suppress writes on archived projects
This commit is contained in:
1 parent
8c3f48113f
commit
51892cf95a
7 files changed
+183
-29
No files matched your search
@@ -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 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 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.
|
||||||
@@ -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 | 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 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 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.
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
|
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
|
||||||
const clear = form('clear:' + c.key);
|
const clear = form('clear:' + c.key);
|
||||||
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
||||||
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending || c.projectArchived || c.archiveUnknown)}></label><button class="btn" type="submit"${disabled(f.pending || c.projectArchived || c.archiveUnknown || current.channelDisabled)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(c.projectArchived || clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(c.archiveUnknown || clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
|
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending || c.projectArchived || c.archiveUnknown)}></label><button class="btn" type="submit"${disabled(f.pending || c.projectArchived || c.archiveUnknown || current.channelDisabled)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(c.projectArchived || clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(c.projectArchived || c.archiveUnknown || clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
|
||||||
}
|
}
|
||||||
// A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not
|
// 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.
|
// 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 `<div class="chat-body">${txt(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
|
if (r?.kind === 'reply') return `<div class="chat-body">${txt(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
|
||||||
if (r?.kind === 'notice') {
|
if (r?.kind === 'notice') {
|
||||||
const v = reverts.get(r.commit) ?? {};
|
const v = reverts.get(r.commit) ?? {};
|
||||||
return `<div class="chat-body chat-notice"><span>${txt(r.text)}</span> ${current.archiveUnknown ? '' : `<button class="btn" type="button" data-revert="${esc(r.commit)}"${disabled(v.pending)}>Revert</button>`} <span class="chat-revert-receipt" role="status">${txt(v.receipt ?? '')}</span></div>`;
|
return `<div class="chat-body chat-notice"><span>${txt(r.text)}</span> ${current.archiveUnknown || current.projectArchived ? '' : `<button class="btn" type="button" data-revert="${esc(r.commit)}"${disabled(v.pending)}>Revert</button>`} <span class="chat-revert-receipt" role="status">${txt(v.receipt ?? '')}</span></div>`;
|
||||||
}
|
}
|
||||||
return `<div class="chat-body">${txt(m.body)}</div>`;
|
return `<div class="chat-body">${txt(m.body)}</div>`;
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
async function revert(commit) {
|
async function revert(commit) {
|
||||||
const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {};
|
const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {};
|
||||||
const project = libraryOf(c, t);
|
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;
|
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.';
|
const unknown = 'Outcome unknown. Read the library status before trying again; nothing was retried.';
|
||||||
reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint();
|
reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint();
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
}
|
}
|
||||||
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||||
c.container.innerHTML = html + '</div>';
|
c.container.innerHTML = html + '</div>';
|
||||||
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 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 repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
|
||||||
const cached = knownRequests.get(c.id);
|
const cached = knownRequests.get(c.id);
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
}
|
}
|
||||||
const remote = c.container.querySelector('[data-chat-remote]');
|
const remote = c.container.querySelector('[data-chat-remote]');
|
||||||
const project = c.projects.find(p => p.id === c.id);
|
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);
|
window.MosaicProjectArchive?.mount(c.container, c);
|
||||||
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||||
const details = c.container.querySelector('.chat-details');
|
const details = c.container.querySelector('.chat-details');
|
||||||
@@ -667,7 +667,7 @@
|
|||||||
document.addEventListener('input', event => {
|
document.addEventListener('input', event => {
|
||||||
if (!current || out()) return;
|
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-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-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;
|
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
|
||||||
const f = event.target.closest('.chat-create');
|
const f = event.target.closest('.chat-create');
|
||||||
@@ -822,7 +822,7 @@
|
|||||||
}
|
}
|
||||||
async function cancelClear() {
|
async function cancelClear() {
|
||||||
const c = current, key = c.key, f = form('clear:' + key);
|
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;
|
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;
|
const op = f.op;
|
||||||
f.pending = true; paint();
|
f.pending = true; paint();
|
||||||
|
|||||||
@@ -7,18 +7,18 @@
|
|||||||
document.addEventListener('mosaic-project-context', event => {
|
document.addEventListener('mosaic-project-context', event => {
|
||||||
if (active && event.detail?.projectId !== active.projectId) { active.dispose(); active = null; }
|
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 (!(container instanceof Element)) return;
|
||||||
if (preserve && active?.projectId === projectId && active.repositoryKey === repositoryKey) {
|
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; }
|
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');
|
const section = document.createElement('section');
|
||||||
section.className = 'project-remote';
|
section.className = 'project-remote';
|
||||||
container.replaceChildren(section);
|
container.replaceChildren(section);
|
||||||
const dispose = () => { generation++; draft = ''; data = null; section.replaceChildren(); };
|
const dispose = () => { disposed = true; generation++; draft = ''; data = null; section.replaceChildren(); };
|
||||||
if (preserve) active = { projectId, repositoryKey, section, dispose };
|
if (preserve) active = { projectId, repositoryKey, section, dispose, setArchived(value) { if (archived !== value) { archived = value; paint(); } } };
|
||||||
else document.addEventListener('mosaic-signed-out', dispose, { once: true });
|
else document.addEventListener('mosaic-signed-out', dispose, { once: true });
|
||||||
const element = (tag, content, attrs = {}) => {
|
const element = (tag, content, attrs = {}) => {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
@@ -40,6 +40,9 @@
|
|||||||
section.append(element('p', data.remote)); return;
|
section.append(element('p', data.remote)); return;
|
||||||
}
|
}
|
||||||
section.append(element('p', 'Saving sets this repository URL only. Saving does not publish.'));
|
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) {
|
if (!canWrite || !data.firstSaveAllowed) {
|
||||||
section.append(element('p', 'This setting is read-only.')); return;
|
section.append(element('p', 'This setting is read-only.')); return;
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
form.addEventListener('submit', async event => {
|
form.addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (pending || stale || signedOut) return;
|
if (pending || stale || signedOut || archived || disposed) return;
|
||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = new URL(input.value); } catch { /* invalid input */ }
|
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; }
|
if (parsed?.username || parsed?.password) { draft = ''; input.value = ''; error = 'Repository URL must not contain credentials.'; paint(); section.querySelector('input')?.focus(); return; }
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
let p = panels.get(id);
|
let p = panels.get(id);
|
||||||
if (!p) {
|
if (!p) {
|
||||||
const stored = load(id);
|
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 ?? '' } };
|
ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } };
|
||||||
panels.set(id, p);
|
panels.set(id, p);
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
function html(p) {
|
function html(p) {
|
||||||
const s = p.state, ui = p.ui;
|
const s = p.state, ui = p.ui;
|
||||||
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
|
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
|
||||||
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 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 : '';
|
const returnHash = /^#\/chat\/threads\//.test(location.hash) || /^#\/projects\/threads\//.test(location.hash) ? location.hash : '';
|
||||||
let body = `${returnHash ? `<a class="request-return" href="${esc(returnHash)}">Return to conversation</a>` : ''}<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
|
let body = `${returnHash ? `<a class="request-return" href="${esc(returnHash)}">Return to conversation</a>` : ''}<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
|
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
|
||||||
: `<p class="request-question answered">Answer received: ${txt(q.text)}. Application is not established by this receipt.</p>`;
|
: `<p class="request-question answered">Answer received: ${txt(q.text)}. Application is not established by this receipt.</p>`;
|
||||||
}
|
}
|
||||||
if (!can) body += p.archiveUnknown ? '<p class="request-readonly small muted">Archive status unavailable. Some changes are unavailable.</p>' : p.stale ? '<p class="request-readonly small muted">Writes are unavailable until the request is read again.</p>' : '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
|
if (!can) body += p.projectArchived ? '<p class="request-readonly small muted">This project is archived. Some changes are unavailable.</p>' : p.archiveUnknown ? '<p class="request-readonly small muted">Archive status unavailable. Some changes are unavailable.</p>' : p.stale ? '<p class="request-readonly small muted">Writes are unavailable until the request is read again.</p>' : '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
|
||||||
if (can && ui.confirm) {
|
if (can && ui.confirm) {
|
||||||
const c = ui.confirm;
|
const c = ui.confirm;
|
||||||
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
|
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
(focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true });
|
(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.
|
// The reads that follow a state: the mode decision the selection waits on and the project picker.
|
||||||
async function side(p) {
|
async function side(p) {
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
|
|
||||||
async function answer(p, again = false) {
|
async function answer(p, again = false) {
|
||||||
const s = p.state, ui = p.ui;
|
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;
|
let rec;
|
||||||
if (again) {
|
if (again) {
|
||||||
rec = p.stored;
|
rec = p.stored;
|
||||||
@@ -386,21 +386,21 @@
|
|||||||
}
|
}
|
||||||
async function mode(p, next) {
|
async function mode(p, next) {
|
||||||
const s = p.state;
|
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 = '';
|
p.ui.receipt = '';
|
||||||
settle(p, await send(p, 'mode', { revision: s.revision, mode_revision: s.mode_revision, mode: next, op: opId('mode') }),
|
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');
|
`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) {
|
async function cancel(p) {
|
||||||
const s = p.state;
|
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;
|
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');
|
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) {
|
async function revoke(p) {
|
||||||
const s = p.state, m = p.material, ui = p.ui;
|
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; }
|
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 = '';
|
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') });
|
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.' : ''}`;
|
const notAnswered = r => `Not answered here: ${r.message} (${r.code}).${r.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}`;
|
||||||
async function choose(p, key) {
|
async function choose(p, key) {
|
||||||
const row = p.inbox, ui = p.ui;
|
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;
|
const revision = p.state.revision, candidate = prdDecision(p.state) ? p.material?.candidate : null;
|
||||||
if (prdDecision(p.state) && !candidate) return;
|
if (prdDecision(p.state) && !candidate) return;
|
||||||
ui.pending = 'choice'; ui.receipt = ''; ui.confirm = null; draw(p);
|
ui.pending = 'choice'; ui.receipt = ''; ui.confirm = null; draw(p);
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
}
|
}
|
||||||
async function confirm(p) {
|
async function confirm(p) {
|
||||||
const c = p.ui.confirm, ui = p.ui;
|
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;
|
c.candidate && (!same(c.candidate, p.material?.candidate) || p.state.wait?.subject !== c.id)) return;
|
||||||
ui.pending = 'confirm'; ui.receipt = ''; draw(p);
|
ui.pending = 'confirm'; ui.receipt = ''; draw(p);
|
||||||
const { res, json } = await post(`/api/decisions/${enc(c.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
|
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 }); }
|
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 ('requestResend' in d) void answer(p, true);
|
||||||
else if ('requestMode' in d) void mode(p, d.requestMode);
|
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 ('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 ('requestCancelKeep' in d) { p.ui.cancelAsk = false; draw(p); }
|
||||||
else if ('requestCancelConfirm' in d) void cancel(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
|
// 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.
|
// 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;
|
if (out() || !container) return;
|
||||||
const mounts = [...container.querySelectorAll('.request-mount[data-request]')].filter(el => ID.test(el.dataset.request));
|
const mounts = [...container.querySelectorAll('.request-mount[data-request]')].filter(el => ID.test(el.dataset.request));
|
||||||
if (!mounts.length) return;
|
if (!mounts.length) return;
|
||||||
@@ -520,6 +520,7 @@
|
|||||||
const p = panel(el.dataset.request);
|
const p = panel(el.dataset.request);
|
||||||
p.writes = cfg.writes;
|
p.writes = cfg.writes;
|
||||||
p.archiveUnknown = archiveUnknown;
|
p.archiveUnknown = archiveUnknown;
|
||||||
|
p.projectArchived = projectArchived;
|
||||||
if (p.state || p.failed) draw(p);
|
if (p.state || p.failed) draw(p);
|
||||||
if (!p.reading && Date.now() - p.at > STALE) void read(p);
|
if (!p.reading && Date.now() - p.at > STALE) void read(p);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, `<button data-revert="${'a'.repeat(40)}">Revert</button>`, '[data-revert]');
|
||||||
|
await force(b, '<form class="request-answer"><textarea>answer</textarea><button>Send</button></form>', '.request-answer', true);
|
||||||
|
for (const [attr, value] of [['resend',''],['mode','autonomous'],['cancel',''],['cancel-confirm',''],['revoke','']]) await force(b, `<button data-request-${attr}="${value}">Go</button>`, `[data-request-${attr}]`);
|
||||||
|
await force(b, '<form class="request-revoke-form"><textarea>reason</textarea><button>Revoke</button></form>', '.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, '<button data-request-choice="yes">Choose</button>', '[data-request-choice]');
|
||||||
|
await force(b, '<button data-request-confirm>Confirm</button>', '[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, '<button data-request-cancel-confirm>Cancel</button>', '[data-request-cancel-confirm]');
|
||||||
|
await force(b, '<form class="request-revoke-form"><textarea>valid reason</textarea><button>Revoke</button></form>', '.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);
|
||||||
|
});
|
||||||
@@ -632,9 +632,7 @@ test('archive navigation exposes saved threads and unarchive with only the two a
|
|||||||
await set('#chat-operation', 'pre-archive');
|
await set('#chat-operation', 'pre-archive');
|
||||||
await click('#chat-operation-form button');
|
await click('#chat-operation-form button');
|
||||||
await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("retired")');
|
await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("retired")');
|
||||||
await b.evaluate(`window.__answers.push({body:{...window.__operation,state:'cancelled'}});true`);
|
assert.equal(await b.evaluate('document.querySelector("#chat-cancel").disabled'), true);
|
||||||
await click('#chat-cancel');
|
|
||||||
await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("cancelled")');
|
|
||||||
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
||||||
await wait(`document.querySelector('a[href="#/chat/rooms/${P}"]')`);
|
await wait(`document.querySelector('a[href="#/chat/rooms/${P}"]')`);
|
||||||
assert.ok(await b.evaluate(`!!document.querySelector('a[href="#/chat/rooms/${P}"]')`));
|
assert.ok(await b.evaluate(`!!document.querySelector('a[href="#/chat/rooms/${P}"]')`));
|
||||||
|
|||||||
Reference in new issue
Block a user