fix(webui): restrict writes when archive state is unknown

This commit is contained in:
jason.woltje committed 2026-11-06 04:48:14 -06:00
1 parent 37f38d8e10
commit 8c3f48113f
9 files changed
+218 -35

No files matched your search

+2
View File
@@ -6287,3 +6287,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-06 | #797 MVP3 project archive round 3 — implementation and verification | Before: r2 head 1c33c342b had two P1s: stale final launch admission and an omitted origin-chat binding downstream. After: a single broker snapshot fences thread and manifest at launch; shared complete request-binding checks fence bootstrap effects, PRD activation and task/requirements admission while preserving accepted receipts and reads. Added permanent race/origin/shared-repository regressions and operator guidance. Node 26: exact-parent negative 5 behavioral failures / 1 control pass; final regressions 6/6, bus excluding socket file 1614/1614, WebUI 583/583, corrected whole release file 226/226. The separate project-files socket test timed out after five passes (exit 124, one cancelled wrapper); C8 had 569/587 after fixture timeouts were fixed, with 17 release-fixture failures subsequently corrected and the known parent adapter-exited-127 failure remaining. Initial failures, full commands and comparisons are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-evidence/ and the report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. No schema/store, supervisor, role, contract or policy changes; no push, merge, deployment or live acceptance. Independent limited re-review remains.
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.
+1
View File
@@ -2091,3 +2091,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-06 | mvp3-archive-r2-child (#797 lane) | feat/mvp3-project-archive-r2 from 3e21d239b: close archived thread/session and product/request/PRD/task/bootstrap/manifest admissions; preserve accepted completion, cleanup and reads; repair archived navigation and two-hook styling | bus 1618/1618, WebUI 583/583, other consumers 230/230, lifecycle/launch 67/67; CLI path run 585/587 with both dependency-fixture failures reproduced then superseded by whole-file case2-install 28/28 after self-link cleanup. Schema stays 3t; budget-supervisor integration fix deferred. Exact runs/failures and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md. Local commit only; independent review pending; no push, merge, deployment or fleet action.
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.
+26 -11
View File
@@ -82,7 +82,7 @@
const threadAccess = () => !!current.thread && !current.shared && scopeWrites(current.thread?.project, current.thread?.instance);
const threadWrites = () => !current.projectArchived && threadAccess();
function sharing(t) {
if (!current.canSend || !threadWrites() || current.tester) return '';
if (!current.canSend || !threadWrites() || current.archiveUnknown || current.tester) return '';
const f = form('share:' + t.id);
return `<section aria-label="Thread sharing"><h2>Share read-only</h2><p>Share with a user (user:id), the business owner's name, or * for everyone granted on this thread.</p><ul>${(current.shares ?? []).map(r => `<li>${txt(r.to)} <button class="btn" type="button" data-unshare="${esc(r.to)}"${disabled(f.pending)}>Unshare</button></li>`).join('')}</ul><form id="chat-share-form"><label>Share with<input id="chat-share-target" value="${esc(f.to ?? '')}" maxlength="200" required></label><button class="btn" type="submit"${disabled(f.pending)}>Share read-only</button></form><p role="status">${txt(f.receipt)}</p></section>`;
}
@@ -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 `<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)}></label><button class="btn" type="submit"${disabled(f.pending || c.projectArchived || 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(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.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
// 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 === 'notice') {
const v = reverts.get(r.commit) ?? {};
return `<div class="chat-body chat-notice"><span>${txt(r.text)}</span> <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 ? '' : `<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>`;
}
@@ -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.canSend || out() || v.pending || !SHA.test(commit) || project === null) return;
if (!c || c.archiveUnknown || !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();
@@ -398,7 +398,8 @@
const keep = capture(), c = current;
let html = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.archiveUnknown) html += '<p role="status">Archive status unavailable. Sending may be refused; refresh to check.</p>';
if (c.archiveUnknown) html += '<p role="status">Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
else if (c.kind === 'thread' && c.projectArchived) html += '<p role="status">This project is archived; its conversation stays readable.</p>';
if (c.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '<p role="status">Showing previously read chat content; it may be out of date.</p>';
if (c.kind === 'room') {
html += c.refused ? heading('Room unavailable') : roomConversation();
@@ -419,7 +420,7 @@
}
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>';
window.MosaicRequests?.mount?.(c.container);
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown });
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);
@@ -480,8 +481,18 @@
paint(); return;
}
const old = current;
current = { container, kind, id, settings: !!settings, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current = { container, kind, id, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
else if (current.archiveUnknown) {
// Keep the previous conversation in place while its archive lookup is pending,
// but immediately withdraw controls whose server paths lack archive guards.
for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true;
window.MosaicRequests?.mount?.(container, { archiveUnknown: true });
const notice = document.createElement('p');
notice.setAttribute('role', 'status');
notice.textContent = 'Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.';
container.querySelector('.chat-area')?.prepend(notice);
}
try {
if (user) {
const config = await fetch('/api/config', { cache: 'no-store', redirect: 'manual', signal });
@@ -512,9 +523,11 @@
const projectId = kind === 'project' ? id : current.thread?.project;
if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester) {
try {
const resolved = await window.MosaicProjectArchive?.resolve(projectId, signal);
if (typeof window.MosaicProjectArchive?.resolve !== 'function') throw Error('archive-unavailable');
const resolved = await window.MosaicProjectArchive.resolve(projectId, signal);
if (g !== generation || out()) return;
if (resolved) current.projects = [...current.projects, resolved];
current.archiveUnknown = false;
} catch (err) {
if (kind !== 'thread' || err.name === 'AbortError') throw err;
if (g !== generation || out()) return;
@@ -523,6 +536,7 @@
current.archiveUnknown = true;
}
}
else if (projectId) current.archiveUnknown = false;
current.projectArchived = current.projects.some(p => p.id === projectId && p.archived === true);
const catalog = kind === 'thread' && !current.shared ? await request(`threads/${id}/sessions`, undefined, signal).catch(err => {
if (err.code === 'access-refused' && !match[3]) return null;
@@ -653,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 || 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 || 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');
@@ -673,6 +687,7 @@
if (!current || !current.canSend || out() || current.channelDisabled) return;
const isThread = action === 'send' || action === 'open';
if ((isThread || action === 'createSession') && !threadWrites()) return;
if (action === 'createSession' && current.archiveUnknown) return;
if (action === 'createProject' && current.user || action === 'createThread' && !scopeWrites(value.project, value.instance)) return;
const targetThread = current.id;
const s = isThread ? state(key) : form(key);
@@ -777,7 +792,7 @@
}
});
async function changeShare(op, to) {
if (!current?.canSend || !threadWrites() || current.tester || out()) return;
if (!current?.canSend || !threadWrites() || current.archiveUnknown || current.tester || out()) return;
const id = current.id, f = form('share:' + id);
if (f.pending || !to.trim()) return;
f.pending = true; f.receipt = ''; paint();
@@ -807,7 +822,7 @@
}
async function cancelClear() {
const c = current, key = c.key, f = form('clear:' + key);
if (!c.canSend || !threadAccess() || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
if (!c.canSend || !threadAccess() || c.archiveUnknown || 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();
+13 -12
View File
@@ -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, 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, 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 ? `<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;
const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale && !p.archiveUnknown, 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 ? `<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>`
: `<p class="request-question answered">Answer received: ${txt(q.text)}. Application is not established by this receipt.</p>`;
}
if (!can) body += 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.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) {
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>
@@ -222,7 +222,7 @@
body += `<div class="request-choices" role="group" aria-label="${prdDecision(s) ? 'Review this exact product PRD version' : 'Choose how to run this request'}"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${s.phase === 'selection' && ['guided', 'autonomous', 'existing'].includes(o.key) ? `<b>${o.key === 'existing' ? 'Existing project' : o.key === 'guided' ? 'Guided' : 'Autonomous'}</b>: ` : ''}${txt(o.text)}</button>`).join(' ')}</div>`;
}
if (can && pickerOpen(s)) {
if (canAttach && pickerOpen(s)) {
const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null;
body += Array.isArray(p.projects)
? `<div class="request-picker" role="group" aria-label="Choose a project">${p.projects.length ? `<p class="small muted">Projects you may use for this request. Picking one asks the project manager to propose it; you confirm it next.</p>${p.projects.map(x =>
@@ -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, 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, 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 || ui.pending || !s || !openQuestion(s)) return;
if (!p.writes || p.archiveUnknown || 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.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.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.ui.pending || !s || ENDED.includes(s.phase)) return;
if (!p.writes || p.archiveUnknown || 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.stale || p.reading || ui.pending || !ui.revokeAsk || !s || !m?.snapshot.id || !isMaterial(m, s)) return;
if (!p.writes || p.archiveUnknown || 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.stale || p.reading || ui.pending || !row || !row.console.options.some(o => o.key === key)) return;
if (!p.writes || p.archiveUnknown || 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.stale || p.reading || ui.pending || !c || c.revision !== p.state?.revision ||
if (!p.writes || p.archiveUnknown || 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 });
@@ -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) {
function mount(container, { archiveUnknown = false } = {}) {
if (out() || !container) return;
const mounts = [...container.querySelectorAll('.request-mount[data-request]')].filter(el => ID.test(el.dataset.request));
if (!mounts.length) return;
@@ -519,6 +519,7 @@
if (!el.isConnected) continue;
const p = panel(el.dataset.request);
p.writes = cfg.writes;
p.archiveUnknown = archiveUnknown;
if (p.state || p.failed) draw(p);
if (!p.reading && Date.now() - p.at > STALE) void read(p);
}
+2
View File
@@ -49,6 +49,7 @@ export const REQUEST_REFUSALS = Object.freeze({
'request-mode-revision-conflict': [409, 'The mode changed. Read the request again.'],
'request-question-closed': [409, 'That question is already answered.'],
'request-question-mismatch': [409, 'That is not the question the project manager is waiting on.'],
'project-archived': [409, 'This project is archived.'],
'request-project-refused': [403, 'That project cannot be picked for this request.'],
'request-existing-project-changed': [409, 'The project mapping changed. Read the project picker again.'],
'request-operation-conflict': [409, 'That operation id was already used for a different change.'],
@@ -71,6 +72,7 @@ const BUS_CODES = {
'peercred-required': 'peercred-required',
'request-not-found': 'request-not-found', 'request-revision-conflict': 'request-revision-conflict',
'request-phase-refused': 'request-phase-refused', 'request-mode-revision-conflict': 'request-mode-revision-conflict',
'project-archived': 'project-archived',
'request-question-closed': 'request-question-closed',
'request-question-mismatch': 'request-question-mismatch', 'request-project-refused': 'request-project-refused',
'request-existing-project-changed': 'request-existing-project-changed',
@@ -70,3 +70,76 @@ test('a failed archive lookup cannot hide the real broker refusal of an archived
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'must be refused');
assert.equal(store.get('SELECT count(*) n FROM messages').n, before);
});
test('unknown status suppresses unguarded controls and retains guarded controls', async t => {
const { question, wait, draft, candidate, DECISION } = await import('./helpers/w15-requests.mjs');
const active = {...draft, snapshot:{id:'snapshot-1',revision:2}};
const current = state({ question, wait:wait('question','question','q-1'), candidate, snapshot:'snapshot-1' });
const {b,navigate} = await setup(t,{states:{[REQUEST]:current}, draft:active, archiveFailure:'403'});
await navigate(1000);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /archive status unavailable.*may be refused/i);
for (const selector of ['#chat-session-form button', '#chat-share-form button', '[data-unshare]', '.request-answer', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-revert]']) {
assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}) || document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector);
}
assert.equal(await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return document.querySelector('#chat-cancel').disabled})()`), true, 'cancel clear');
for (const selector of ['#chat-send-form button', '#chat-resume', '#chat-clear']) assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector);
assert.match(await b.evaluate('document.querySelector(".request-panel").textContent'), /Which platform/);
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('unknown status suppresses decision writes but keeps the guarded picker', async t => {
const { wait, DECISION } = await import('./helpers/w15-requests.mjs');
const choice = state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)});
const inbox = [{id:DECISION,question:'Choose',console:{kind:'decision',options:[{key:'existing',text:'Existing project'}]}}];
const {b,navigate} = await setup(t,{states:{[REQUEST]:choice},inbox,projects:[{project:'old',name:'Old'}],archiveFailure:'403'});
await navigate(1000);
assert.equal(await b.evaluate("!!document.querySelector('[data-request-choice]')"), false);
await b.evaluate(`window.__states['${REQUEST}']=${JSON.stringify(state({project:null,phase:'selection',route:'existing',wait:wait('setup','request-existing-project',REQUEST)}))};document.querySelector('[data-request-reload]').click();true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-request-project]'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('picker missing')})()`);
assert.equal(await b.evaluate("document.querySelector('[data-request-project]').disabled"),false);
});
test('a later successful lookup restores live controls or shows the archived state', async t => {
const { question, wait, draft, candidate, DECISION } = await import('./helpers/w15-requests.mjs');
const current = state({question, candidate, snapshot:'snapshot-1', wait:wait('question','question','q-1')});
const {b,navigate} = await setup(t,{states:{[REQUEST]:current},draft:{...draft,snapshot:{id:'snapshot-1',revision:2}},shares:[{to:'user:reader'}],archiveFailure:'403',inbox:[{id:DECISION,question:'Choose',console:{kind:'decision',options:[{key:'existing',text:'Existing project'}]}}]});
await navigate(1000);
assert.equal(await b.evaluate("!!document.querySelector('.request-answer')"), false);
await b.evaluate(`window.__archiveFailure=null;window.__liveProjects=[{id:'${'20000000-0000-4000-8000-000000000002'}',name:'Live',repo:null}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.request-answer') && document.querySelector('#chat-session-form') && document.querySelector('#chat-share-form'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('live controls missing')})()`);
for (const selector of ['.request-answer', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-unshare]']) assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), true, selector);
assert.equal(await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return document.querySelector('#chat-cancel').disabled})()`), false);
const selection = state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)});
await b.evaluate(`window.__states['${REQUEST}']=${JSON.stringify(selection)};document.querySelector('[data-request-reload]').click();true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-request-choice]'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('decision not restored')})()`);
await b.evaluate(`window.__liveProjects=[];window.__archiveRows=[{id:'${'20000000-0000-4000-8000-000000000002'}',name:'Archived',repo:null,archived:true}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(!document.querySelector('#chat-send-form') && document.querySelector('.chat-conversation'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('archived controls still present')})()`);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /this project is archived/i);
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-area").textContent'), /archive status unavailable/i);
});
test('real authenticated attach on an archived origin shows a known refusal', async t => {
const { atPicker } = await import('./helpers/archive-origin.mjs');
const x = await atPicker(t);
const 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 session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session);
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'}],archiveFailure:'403',forwardSend:true},{forwardSend});
await navigate(1000);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){const e=document.querySelector('[data-request-project=old]');if(e){e.click();return true}await new Promise(r=>setTimeout(r,25))}throw Error('picker missing')})()`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.request-receipt')?.textContent.match(/Not picked|Outcome unknown/))return true;await new Promise(r=>setTimeout(r,25))}throw Error('receipt missing')})()`);
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);
});
@@ -0,0 +1,80 @@
// #797 / #1839 Task 3: the owner's Console controls over the request journal, and the PM's question.
// The webui side is packages/webui/tests/request-http.test.mjs; this file drives the broker verbs
// those routes call, with a real capability for every caller.
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { Store } from '../../../bus/src/store.mjs';
import { Broker } from '../../../bus/src/broker.mjs';
import { createRequestStore } from '../../../bus/src/request-store.mjs';
import { createRequestLifecycle } from '../../../bus/src/request-lifecycle.mjs';
const hash = x => createHash('sha256').update(x).digest('hex');
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
const SUBJECT = { provider: 'oidc', iss: 'https://id.test', sub: 'jason' };
function setup(t, overrides = {}) {
const root = mkdtempSync(join(tmpdir(), 'request-console-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: Object.fromEntries(['pm', 'cto', 'coder', 'otherpm', 'chat'].map(key => [key, {
definition: key === 'otherpm' ? 'pm' : key === 'chat' ? 'assistant' : key, authority: { withinRole: ['message.send'], crossRole: [] },
}])), projects: { factory: { root: join(root, 'factory') }, old: { root: join(root, 'old'), repo: join(root, 'old-code') },
library: { kind: 'library', root: join(root, 'library') }, unknown: { root: join(root, 'unknown') } } };
const plans = (state, args, source, existing) => ['autonomous', 'guided'].map(mode => ({
version: 1, business: state.business, request: state.request, project: args.project, name: args.name, chat_project: null, mode,
organization_revision: 'org-1', source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] },
code_root: existing ? business.projects[args.project].root : join(root, 'products/demo', args.project, 'code'),
records_root: existing ? business.projects[args.project].repo : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project },
staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'cto', chief: 'cto' },
intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false },
project_vars: {}, existing,
}));
const host = {
listProjects: () => [
{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'library', name: 'Library', tracker: { root: 1, project: 2 } },
{ business: 'foreign', project: 'old', name: 'Foreign', tracker: { root: 1, project: 2 } },
{ business: 'demo', project: 'unknown', name: 'Unknown', tracker: null },
],
preparePlans: (state, args, source) => plans(state, args, source, false),
prepareExisting: (state, args, source) => plans(state, args, source, true)[0],
admitPlan: () => ({ ready: true }),
};
let lifecycle, journal;
const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: services => {
journal = createRequestStore(store); lifecycle = createRequestLifecycle({ journal, services: { ...host, ...overrides, ...services } }); return lifecycle;
} });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }),
console: b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }) };
for (const role of ['pm', 'cto', 'coder', 'otherpm']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' });
b.request(caps[role], { verb: 'role.claim' });
}
const call = async (who, verb, args = {}) => b.request(caps[who], { verb, args });
const originProject=b.request(caps.human,{verb:'project.create',args:{name:'Origin chat'}});
const originThread=b.request(caps.human,{verb:'thread.create',args:{project:originProject.id,name:'pm',instance:'chat'}});
const source=b.request(caps.human,{verb:'thread.send',args:{thread:originThread.id,body:intent}});
caps.chat=b.bindLaunch({business:'demo',role:'chat',run:'chat-run',harness:'pi',thread:originThread.id});
b.request(caps.chat,{verb:'role.claim'});
b.request(caps.chat,{verb:'message.send',args:{to:'pm',body:intent,in_reply_to:source.id}});
const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 });
const existing = (state, op) => ({ request: state.request, revision: state.revision, op, source_project: 'old', project: 'old', name: 'Old', tracker_project: 2 });
return { root, b, store, call, source, propose, existing, business, get journal() { return journal; }, get lifecycle() { return lifecycle; } };
}
const n = (x, sql, ...p) => x.store.get(sql, ...p).n;
const get = (x, who = 'console') => x.call(who, 'request.get.console', { request: x.source.request });
async function atSelection(t) {
const x = setup(t), opened = await x.call('pm', 'request.open', { request: x.source.request });
x.state = await x.call('pm', 'request.propose', x.propose(opened));
return x;
}
async function atPicker(t) {
const x = await atSelection(t);
await x.call('human', 'decision.resolve', { id: x.state.selection.decision, choice: 'existing' });
x.state = await x.lifecycle.resume('demo', x.source.request);
return x;
}
export { atPicker };
+13 -12
View File
@@ -15,21 +15,21 @@ export const wait = (kind, code, subject) => ({ kind, code, subject });
export const question = { id: 'q-1', text: 'Which platform?', answer: null, options: [] };
export async function setup(t, boot, { forwardSend = null } = {}) {
const thread = boot.thread || THREAD, project = boot.project || thread;
const thread = boot.thread || THREAD, project = boot.project || thread, requestId = boot.request || REQUEST;
const f = await fixture(); t.after(() => f.close());
const b = await browser({ machine: false }); t.after(() => b.close());
const css = await readFile(new URL('../../src/public/requests.css', import.meta.url), 'utf8');
const source = `(() => {
const RealDate = Date; window.Date = class extends RealDate { constructor(...args) { super(...(args.length ? args : ['2026-11-05T12:00:00Z'])); } };
const realFetch = window.fetch.bind(window);
const boot = ${JSON.stringify(boot)}; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = [];
const boot = ${JSON.stringify(boot)}; window.__archiveFailure = boot.archiveFailure; window.__archiveRows = boot.archiveRows || []; window.__liveProjects = []; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = [];
document.addEventListener('mosaic-request-state', e => window.__events.push(e.detail));
document.addEventListener('DOMContentLoaded', () => { window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } }; });
document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); });
window.fetch = async (url, init = {}) => {
const path = String(url), method = init.method || 'GET';
const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}});
if (method === 'POST' && boot.forwardSend && path.endsWith('/messages')) return realFetch(url, init);
if (method === 'POST' && boot.forwardSend && (path.endsWith('/messages') || path.endsWith('/attach'))) return realFetch(url, init);
if (method !== 'GET') { window.__writes.push({path, method}); if (path === '/api/decisions/${DECISION}/confirmation' && boot.allowConfirmation) return json({id:'${DECISION}',action:'answer',kind:'decision',choice:'yes',text:'Approve exact PRD',question:'Approve the exact PRD?',confirmation:'fixture-token'}); return json({error:'fixture-refusal'}, 403); }
if (path === '/api/config') return json({send:true, requests:true, requestWrites:true});
if (path === '/api/processing') return json({version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false});
@@ -38,18 +38,19 @@ export async function setup(t, boot, { forwardSend = null } = {}) {
if (path.startsWith('/api/bus/')) return json({rows:[], unread:0, at:'2026-11-05T12:00:00Z'});
if (path === '/api/business') return json({projects:[]});
if (path === '/api/chat/instances') return json({rows:[{instance:'assistant',name:'assistant'}]});
if (path === '/api/chat/projects') return json({rows:[]});
if (path === '/api/chat/projects') return json({rows:window.__liveProjects});
if (path === '/api/chat/projects/archived') {
if (boot.archiveFailure === 'network') throw new TypeError('fixture network failure');
if (boot.archiveFailure === 'malformed') return json({rows: 'invalid'});
if (boot.archiveFailure) return json({error:'unavailable'}, 403);
return json({rows:[]});
if (window.__archiveFailure === 'network') throw new TypeError('fixture network failure');
if (window.__archiveFailure === 'malformed') return json({rows: 'invalid'});
if (window.__archiveFailure) return json({error:'unavailable'}, 403);
return json({rows:window.__archiveRows});
}
if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${thread}',name:'default',default:true}]});
if (path.startsWith('/api/chat/threads') && path.endsWith('/shares')) return json({rows:boot.shares || []});
if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${thread}',name:'default',default:true,state:'active',revision:1}]});
if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${thread}',project:'${project}',name:'Project',instance:'assistant'}]});
if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${REQUEST}'}],after:'${MESSAGE}',hasMore:false});
if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${requestId}'}],after:'${MESSAGE}',hasMore:false});
const m=/^\\/api\\/requests\\/([^/]+)(?:\\/(projects|draft))?$/.exec(path);
if (m) { if (m[2] === 'projects') return json([]); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); }
if (m) { if (m[2] === 'projects') return json(boot.projects || []); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); }
window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403);
};
})();`;
@@ -60,7 +61,7 @@ export async function setup(t, boot, { forwardSend = null } = {}) {
b.on('Runtime.consoleAPICalled', e => { if (e.type === 'error') consoleErrors.push(e.args.map(a => a.value || a.description || '').join(' ')); });
b.on('Fetch.requestPaused', e => {
const url = e.request.url;
if (forwardSend && e.request.method === 'POST' && url.endsWith('/messages')) {
if (forwardSend && e.request.method === 'POST' && (url.endsWith('/messages') || url.endsWith('/attach'))) {
void forwardSend(e.request).then(result => b.call('Fetch.fulfillRequest', { requestId:e.requestId, responseCode:result.status, responseHeaders:[{name:'content-type',value:'application/json'}], body:Buffer.from(JSON.stringify(result.body)).toString('base64') })).catch(() => b.call('Fetch.failRequest', { requestId:e.requestId, errorReason:'Failed' }));
return;
}
@@ -519,3 +519,11 @@ test('the signed-in owner draft and revoke listener use the snapshot revision an
assert.deepEqual(writes[1].args, BODIES.revoke);
assert.equal(writes[1].ctx.subject.sub, 'user-a');
});
test('an archived project attachment is a known 409 refusal', async () => {
const s = setup({ write: async () => { throw new BusReadError('project-archived'); } });
const res = await s.post('attach');
assert.equal(res.status, 409);
assert.equal(res.json().error, 'project-archived');
assert.equal(s.writes.length, 1);
});