fix(webui): recover archived organizations and console controls

This commit is contained in:
jason.woltje committed 2026-11-09 13:29:23 -06:00
1 parent c9c409ac4e
commit d6eff452e3
10 files changed
+132 -9

No files matched your search

+19
View File
@@ -1470,6 +1470,25 @@
if (path === '/business') { location.replace('#/settings/business'); return; }
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
// Re-read the scoped capability as the Console and bus recover. A failed read
// withdraws the control; the chat renderer retains the draft across repaint.
if (window.MosaicOrg && !isTester) {
try {
const response = await fetch('/api/config', { cache: 'no-store', redirect: 'manual' });
if (!current()) return;
if (response.type === 'opaqueredirect') { signOut(); return; }
if (response.status === 401) { signOutSso(); return; }
const config = response.ok ? await response.json() : null;
if (!current()) return;
if (response.ok) window.MosaicIcons.succeeded(); else window.MosaicIcons.failed();
chatSend = config?.chatSend === true;
window.MosaicOrgBusStopped = config?.devOwner === true && config?.orgSetup === true && config?.chatSend === false;
} catch {
if (!current()) return;
window.MosaicIcons.failed(); chatSend = false; window.MosaicOrgBusStopped = false;
}
if (!current()) return;
}
// Row 177 (#1675): the tester Console writes to its own threads.
const focusedHeading = document.activeElement === $('bus-view').querySelector('h1');
await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: (window.MosaicOrg ? chatSend : canSend) || isTester, tester: isTester, navigated });
+3
View File
@@ -75,3 +75,6 @@
.chat-room-picker label { display: block; padding-block: .45rem; }
.chat-audience { margin: .25rem 0; }
.chat-projects .panel { border-radius: 4px; }
#chat-send-form textarea:disabled, #chat-room-send-form textarea:disabled { opacity: .55; cursor: not-allowed; }
#chat-send-form button:disabled, #chat-room-send-form button:disabled { opacity: .5; cursor: not-allowed; }
+3 -3
View File
@@ -319,7 +319,7 @@
}
const roomName = instance => current.members?.find(m => m.instance === instance)?.name || instance;
function roomOpenControl(project) {
if (window.MosaicOrg) return '<p>Room changes are unavailable.</p>';
if (window.MosaicOrg) return '<p>Room changes are unavailable on this Console.</p>';
if (!roomOwner()) return '';
if (current.projects.some(p => p.id === project && p.archived)) return `<p><a href="${roomPath(esc(project))}">Read room</a></p>`;
const f = form('room-open:' + project);
@@ -417,7 +417,7 @@
if (s.receiptIds?.every(id => s.rows.some(m => m.ids.includes(id)))) { s.receipt = 'Message shown in the room.'; s.receiptIds = null; }
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = roomError(err);
current.error = window.MosaicOrg && err.code === 'room-not-open' ? 'Room changes are unavailable on this Console.' : roomError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; current.projects = []; }
@@ -630,7 +630,7 @@
} else {
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
html += '</div>';
// Drafts already live in the focused textarea; their changing text is not a
// reason for a background read to replace the control.
+2 -1
View File
@@ -4,7 +4,8 @@
.org-admin h4{font-size:14px;font-weight:700;margin:0 0 8px;color:var(--strong)}
.org-help{display:block;font-size:13px;color:var(--muted);margin:4px 0 0}
.org-field-err{display:block;font-size:13px;color:var(--danger);margin:4px 0 0}
.org-field-err code,.org-errors code,.org-verb code{overflow-wrap:anywhere}
.org-field-err code,.org-errors code{overflow-wrap:anywhere}
.org-verb code{overflow-wrap:normal;word-break:normal}
.org-start-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:start}
.org-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:center}
.org-inline label{grid-column:1/-1;font-size:14px;font-weight:600;color:var(--strong)}
+12 -5
View File
@@ -75,6 +75,7 @@
<section id="org-archived" class="panel" tabindex="-1" hidden aria-labelledby="org-archived-title">
<h3 id="org-archived-title">Organization archived</h3>
<p>Editing and setup are unavailable while archived. Files remain in place; restoring does not start the bus.</p>
<p id="org-restore-verb" class="org-verb"></p>
<button type="button" id="org-restore" data-write>Restore organization</button>
</section>
<p id="org-archive-error" class="org-field-err" role="alert"></p>
@@ -247,6 +248,7 @@
editorDirty = false; setupDirty.clear(); $('org-new-id').value = '';
$('org-editor').hidden = false; $('org-delete').hidden = mode !== 'edit'; resetDelete(); resetArchive(); $('org-delete-success').hidden = true;
$('org-archive').hidden = mode !== 'edit' || state !== 'active'; $('org-archived').hidden = mode !== 'edit' || state !== 'archived';
$('org-restore-verb').textContent = state === 'archived' ? `Same as mosaic org restore ${id} --if-revision ${revision}` : '';
$('org-editor-title').textContent = mode === 'create' ? `New organization: ${id}` : `Organization: ${id}`;
$('org-editor-verb').innerHTML = verb ? `Same as <code>${txt(verb)}</code>` : '';
$('org-save').textContent = mode === 'create' ? 'Save organization' : 'Save changes';
@@ -415,6 +417,9 @@
if (!alive) return;
if (r.status !== 200) { st.setup = null; $('org-setup').hidden = false; $('org-steps').innerHTML = `<li class="panel org-step"><p class="org-field-err">Setup could not be read back (${txt(r.body.error || r.status)}). ${txt(r.body.message || '')}</p><button type="button" class="org-secondary" data-readback>Read back again</button></li>`; return; }
st.setup = r.body;
const orgs = window.MosaicOrg ? await window.MosaicOrg.readList() : [];
if (!alive) return;
st.busDown = orgs.find(org => org.id === id)?.busHealthy === false;
for (const key of [...st.unknown]) if (stepState(key)?.state !== 'unknown') st.unknown.delete(key);
renderSetup();
}
@@ -430,7 +435,7 @@
const keys = stepKeys(); let blocked = false, firstOpen = null;
$('org-setup').hidden = false;
$('org-steps').innerHTML = keys.map((key, i) => {
const s = { ...stepState(key) }; if (st.unknown.has(key) && s.state !== 'done') s.state = 'unknown';
const s = { ...stepState(key) }; if (st.unknown.has(key)) s.state = 'unknown';
if (s.lastError && s.state !== 'done') s.state = s.state === 'unknown' ? 'unknown' : 'failed';
const [stateLabel, cls] = STATE[s.state] || STATE.unknown;
const ready = !blocked && s.state !== 'done' && s.state !== 'unavailable';
@@ -454,7 +459,7 @@
<label class="org-risk"${remote ? ' hidden' : ''}><input type="checkbox" data-risk${c.risk ? ' checked' : ''}${!remote && s.lastError && !c.risk ? ' aria-invalid="true"' : ''}> I accept that a local-only history has no copy anywhere else and is lost with this disk.</label>
<div class="settings-actions"><button type="button" data-run="${txt(key)}">${busy ? 'Working… <span class="settings-spinner" aria-hidden="true"></span>' : key === 'queue' ? 'Set up checkout' : 'Set up repository'}</button></div>
</fieldset>` : ''}
${s.state !== 'done' && s.state !== 'unknown' && key === 'bus' ? `<div class="settings-actions"><button type="button" data-run="bus" ${ready && canWrite && !busy ? '' : 'disabled'}>${busy ? 'Starting… <span class="settings-spinner" aria-hidden="true"></span>' : 'Start the bus'}</button>${ready ? '' : '<span class="org-help">Finish the steps above first.</span>'}</div>` : ''}
${key === 'bus' && canWrite && !st.unknown.has(key) && (st.busDown || (s.state !== 'done' && s.state !== 'unknown')) ? `<div class="settings-actions"><button type="button" data-run="bus" ${(ready || st.busDown) && canWrite && !busy ? '' : 'disabled'}>${busy ? 'Starting… <span class="settings-spinner" aria-hidden="true"></span>' : 'Start the bus'}</button>${ready || st.busDown ? '' : '<span class="org-help">Finish the steps above first.</span>'}</div>` : ''}
</li>`;
}).join('');
for (const card of root.querySelectorAll('.org-step')) if (st.choice.has(card.dataset.step) && card.querySelector('.org-git')) showVerb(card);
@@ -490,6 +495,7 @@
else if (r.body.error === 'readback-failed') { st.unknown.add(key); s.lastError = r.body.message; }
else if (r.body.errors?.length) s.lastError = r.body.errors.map(e => `${e.field}: ${e.message}`).join(' ');
else s.lastError = r.body.message || `Refused (${r.body.error || r.status}).`;
if (key === 'bus' && r.status >= 400 && r.status < 500) s.lastError += ` If the host lock is stale, run mosaic bus recover ${id}.`;
status(`Step incomplete: ${s.lastError}`);
renderSetup(); root.querySelector(`[data-step="${CSS.escape(key)}"] h4`)?.setAttribute('tabindex', '-1'); root.querySelector(`[data-step="${CSS.escape(key)}"] h4`)?.focus();
}
@@ -530,7 +536,7 @@
function resetArchive() {
$('org-archive-confirm').hidden = true; $('org-archive-open').hidden = false;
$('org-archive-error').textContent = ''; $('org-archive-success').hidden = true;
$('org-archive-final').disabled = !canWrite; $('org-restore').disabled = !canWrite;
$('org-archive-final').disabled = !canWrite; $('org-archive-final').textContent = 'Archive'; $('org-restore').disabled = !canWrite;
}
async function refreshOrganizations() {
const rows = window.MosaicOrg ? await window.MosaicOrg.refreshList() : [];
@@ -544,7 +550,7 @@
}
async function runLifecycle(step) {
const id = st.id, revision = st.revision, button = $(step === 'archive' ? 'org-archive-final' : 'org-restore');
button.disabled = true; $('org-archive-error').textContent = '';
button.disabled = true; if (step === 'archive') button.textContent = 'Archiving…'; $('org-archive-error').textContent = '';
try {
const reply = await api('POST', `/api/orgs/${encodeURIComponent(id)}/${step}`, { revision });
if (!alive) return;
@@ -558,6 +564,7 @@
} else $('org-archived').focus();
status(`Organization ${step === 'archive' ? 'archived' : 'restored'}.`);
} catch {
if (step === 'archive') button.textContent = 'Archive';
$('org-archive-error').textContent = `The outcome is unknown. Inspect the organization before retrying: mosaic org ${step} ${id} --if-revision ${revision}.`;
}
}
@@ -618,7 +625,7 @@
[$('org-steps'), 'input', e => { const card = e.target.closest('.org-step'); if (card && e.target.matches('[data-remote]')) { keepChoice(card); showVerb(card); } }],
];
for (const [el, ev, fn] of handlers) el.addEventListener(ev, fn);
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(async config => {
fetch(new URL('/api/config', location.href), { cache: 'no-store' }).then(r => r.json()).then(async config => {
if (!alive) return;
canWrite = (config.orgSetup === true || config.send === true) && config.tester !== true && config.user !== true;
root.querySelectorAll('[data-write]').forEach(el => { el.disabled = !canWrite; });
@@ -329,6 +329,13 @@ async function userPage(t, config) {
return server.address().port;
}
test('a granted user opening an archived organization route has no enabled lifecycle controls', async t => {
const b = await browser(); t.after(() => b.close());
const port = await userPage(t, { user: true, send: true });
await b.navigate(`http://127.0.0.1:${port}/#/orgs/beta/settings/organizations?org=beta`);
assert.equal(await b.evaluate(`!!document.querySelector('#org-restore:not(:disabled), #org-delete-open:not(:disabled), #org-delete-final:not(:disabled)')`), false);
});
test('R6 G7 the hub label and plain peer links render from config on both pages', async t => {
const links = 'JSON.stringify({label:document.querySelector("#hub-label")?.textContent,hidden:document.querySelector("#federation").hidden,peers:[...document.querySelectorAll("#federation a")].map(a=>[a.getAttribute("href"),a.textContent,a.attributes.length])})';
const expected = { label: 'This Console: hub', hidden: false, peers: [['https://factory.example.test', 'Software factory', 2]] };
@@ -69,6 +69,7 @@ test('archive does not announce success for an unexpected response', { timeout:
await f.b.evaluate(`const original=fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/archive')?Promise.resolve(new Response(JSON.stringify({outcome:'done',step:'bus'}),{status:200})):original(input,init);document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!!document.querySelector('#org-archive-error').textContent`);
assert.match(await f.b.evaluate(`document.querySelector('#org-archive-error').textContent`), /outcome is unknown/i);
assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-final').textContent`), 'Archive');
assert.equal(await f.b.evaluate(`document.querySelector('#org-archived').hidden`), true);
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
});
@@ -109,3 +110,49 @@ test('archiving the last active organization shows the no-organization state', {
await f.wait(`window.MosaicOrg?.id === 'alpha'`);
assert.equal((await registry.listOrgs(f.ctx))[0].state, 'active');
});
test('fresh archived organization page offers owner restore while writes stay refused', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`), false);
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`), false);
assert.match(await f.b.evaluate(`document.querySelector('#org-archived').innerText`), /mosaic org restore beta --if-revision \d+/);
const refused = await f.b.evaluate(`Promise.all(['/api/orgs/beta/chat/projects','/api/orgs/beta/chat/threads/00000000-0000-0000-0000-000000000000/messages'].map(url=>fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:'{}'}).then(r=>r.status)))`);
assert.deepEqual(refused, [409, 409]);
await f.b.evaluate(`document.querySelector('#org-restore').click()`);
await f.wait(`!document.querySelector('#org-archive-success').hidden`);
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
});
test('owner can start a down bus after setup recorded done and sees recovery guidance on refusal', { timeout: 45000 }, async t => {
const f = await fixture(t);
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
await f.open('alpha');
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`), true);
await f.b.evaluate(`document.querySelector('[data-step="bus"] [data-run="bus"]').click()`);
await f.wait(`document.querySelector('[data-step="bus"]').innerText.includes('stale bus host lock')`);
assert.match(await f.b.evaluate(`document.querySelector('[data-step="bus"]').innerText`), /mosaic bus recover alpha/);
});
for (const capability of [{ user:true, orgSetup:false }, { tester:true, orgSetup:false }, { orgSetup:false }]) {
test(`archived restore stays disabled without owner capability ${JSON.stringify(capability)}`, { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.call('Page.addScriptToEvaluateOnNewDocument', {source:`{const old=window.fetch;window.fetch=(input,init)=>input instanceof URL && input.pathname==='/api/config'?Promise.resolve(new Response(JSON.stringify(${JSON.stringify(capability)}),{status:200})):old(input,init)}`});
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`),true);
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').disabled`),true);
});
}
test('archive displays progress while its request is in flight', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`{const old=window.fetch;window.fetch=(input,init)=>String(input).endsWith('/archive')?new Promise(resolve=>window.releaseArchive=()=>old(input,init).then(resolve)):old(input,init);document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()}`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-archive-final').textContent`),'Archiving…');
await f.b.evaluate(`window.releaseArchive()`); await f.wait(`!document.querySelector('#org-archived').hidden`);
});
@@ -121,3 +121,14 @@ test('changed requirement content retains the disclosure and focused summary',as
const result=await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');return {open:d.open,focused:document.activeElement===d.querySelector('summary')}});
assert.deepEqual(result,{open:true,focused:true});
});
test('closed room on an organization Console offers no unusable open action', async t => {
const {b,render,wait}=await setup(t,'/projects/rooms/'+P);
await b.evaluate(`window.MosaicOrg={id:'alpha',route:()=>'/projects/rooms/${P}'};window.reviewClosed=true`);
await render('/projects/rooms/'+P);
await wait(`document.querySelector('.chat-room')`);
const result=await b.evaluate(`({text:document.querySelector('.chat-area').innerText,open:!!document.querySelector('[data-room-open-inline],#chat-room-open')})`);
assert.match(result.text,/Room changes are unavailable on this Console\./);
assert.doesNotMatch(result.text,/Open the room before sending/);
assert.equal(result.open,false);
});
@@ -266,3 +266,18 @@ test('a stopped organization bus has truthful chat guidance', async t => {
assert.match(text, /bus is stopped.*Start the bus/i);
assert.doesNotMatch(text, /This Console only reads/);
});
test('organization conversation recovers send capability and preserves its draft after an outage', async t => {
const { fixture } = await import('./fixture.mjs');
const { P, T, stub } = await import('./helpers/projects-review-fixture.mjs');
const f=await fixture(); t.after(()=>f.close());
const b=await browser({machine:false}); t.after(()=>b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub + `\nwindow.MosaicOrg={id:'alpha',route:()=>'/projects/threads/${T}',list:Promise.resolve([]),readList:async()=>[]};window.configUp=false;{const old=fetch;window.fetch=(url,init)=>String(url)==='/api/config'?Promise.resolve(new Response(JSON.stringify({chatSend:window.configUp,orgSetup:true,devOwner:true,sso:true}),{status:200})):old(url,init)}`});
await b.navigate(f.base+'/#/projects/threads/'+T);
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expr)})})()`);
await wait(`document.querySelector('#chat-body')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
await b.evaluate(`const el=document.querySelector('#chat-body');el.value='draft kept';el.dispatchEvent(new Event('input',{bubbles:true}));window.configUp=true;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('#chat-body').disabled`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'draft kept');
});
@@ -163,3 +163,16 @@ test('sidebar sprite is requested again after a failed read and recovery', async
const after = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
assert.notEqual(after,before);
});
test('sidebar sprite is retried after an outage redraw before reads recover', async t => {
const {b,wait}=await setup(t,'/projects');
await wait(`document.querySelector('#sections use[href*="icons.svg"]')`);
const before=await b.evaluate(`document.querySelector('#sections use').getAttribute('href')`);
await b.evaluate(`{const old=window.fetch;window.netDown=true;window.fetch=(url,init)=>window.netDown && String(url).startsWith('/api/')?Promise.reject(new TypeError('offline')):old(url,init);document.querySelector('#refresh').click()}`);
await wait(`document.querySelector('.chat-error')`);
await b.evaluate(`document.querySelector('#refresh').click()`);
await b.evaluate(`new Promise(r=>setTimeout(r,100))`);
await b.evaluate(`window.netDown=false;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('.chat-error') && document.querySelector('#sections use[href*="?retry="]')`);
assert.notEqual(await b.evaluate(`document.querySelector('#sections use').getAttribute('href')`),before);
});