fix(webui): sp1 review round, a switch carries nothing across and first run keeps its board (#1873, lane sp1)

- First run (no organization yet) keeps #/ as its board; the router no longer rewrites it to
  #/dashboard, which first run cannot draw.
- An organization switch stops the agents section's request poll and clears what it held. A
  route-change restart chain finishes on the organization it started in.
- A write in flight at a switch drops its answer: an org epoch in bus.js, checked after each
  await. The switch clears every busy flag, receipt and reply, and the doc drafts.
- The bus banner reads org state again only when the route crosses /dashboard, not on every
  hashchange inside one organization.
- The down banner does not repeat its heading as the reason.
- Tests: first run wordmark, poll stop on switch, restart pinned to its organization, settings
  save in flight at a switch, banner without a repeated reason. The org-switch test comment and
  wait match the in-place switch.
This commit is contained in:
jason.woltje committed 2026-11-14 23:19:05 -06:00
1 parent e24e7e93a2
commit bb2f79f3d9
7 files changed
+226 -64

No files matched your search

+34 -15
View File
@@ -76,8 +76,11 @@
if (!usable() || busy || pending || !view) return; if (!usable() || busy || pending || !view) return;
const [url, payload] = plan(action, name); const [url, payload] = plan(action, name);
busy = true; confirming = null; receipt = ''; redraw(); busy = true; confirming = null; receipt = ''; redraw();
const gen = generation;
let answer; let answer;
try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; } try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
// #1873 SP1: an organization switch while this was in flight drops the answer; the switch already cleared busy.
if (gen !== generation) return;
busy = false; busy = false;
if (answer === null) return; if (answer === null) return;
const { ok, result } = answer; const { ok, result } = answer;
@@ -122,8 +125,10 @@
const instance = create.instance.trim(), definition = create.definition; const instance = create.instance.trim(), definition = create.definition;
if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; } if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; }
busy = true; receipt = ''; redraw(); busy = true; receipt = ''; redraw();
const gen = generation;
let answer; let answer;
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; } try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
if (gen !== generation) return;
busy = false; busy = false;
if (answer === null) return; if (answer === null) return;
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); } if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
@@ -154,19 +159,31 @@
document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); }); document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); });
// A sign-out stops any poll; nothing that was asked is retried. // A sign-out stops any poll; nothing that was asked is retried.
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; }); document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
// #1873 SP1: opening another organization stops any poll and clears what this section held for the
// old one. A request already sent finishes on the old organization's bus; its page reads it again there.
document.addEventListener('mosaic-org-switch', () => {
generation++; pending = null; receipt = ''; editing = null; confirming = null; busy = false;
create = { instance: '', definition: '' };
for (const k of Object.keys(reasons)) delete reasons[k];
});
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One // F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate // terminate; then reads of the instances until the run has left (up to the 60 s regenerate
// allows); then one launch. A thread definition is not launched: its next message starts it. // allows); then one launch. A thread definition is not launched: its next message starts it.
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried. // A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
const restarts = Object.create(null); let restarts = Object.create(null);
// #1873 SP1: the chain's reads and writes go to the organization it started in, which the fetch
// wrapper passes through unchanged. A switch clears what the new page shows; the old chain keeps
// its own entry and stops painting.
const pin = (org, path) => org ? `/api/orgs/${encodeURIComponent(org)}/${path.slice('/api/'.length)}` : path;
document.addEventListener('mosaic-org-switch', () => { restarts = Object.create(null); });
const RESTART_WAIT = 30; const RESTART_WAIT = 30;
function restartMarkup(name, thread = false) { function restartMarkup(name, thread = false) {
const r = restarts[name]; const r = restarts[name];
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`; return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
} }
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => { const paintRestart = (name, org = window.MosaicOrg?.id ?? null) => (window.MosaicOrg?.id ?? null) !== org ? undefined : document.querySelectorAll('.agent-restart').forEach(el => {
const b = el.querySelector('[data-agent-restart]'); const b = el.querySelector('[data-agent-restart]');
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes'); if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
}); });
@@ -179,45 +196,47 @@
return { ok: res.ok, result }; return { ok: res.ok, result };
} }
// The request's closing read, or a reason the chain stops. // The request's closing read, or a reason the chain stops.
async function closing(request) { async function closing(request, org) {
for (let tries = 0; tries < POLL_LIMIT; tries++) { for (let tries = 0; tries < POLL_LIMIT; tries++) {
await new Promise(r => setTimeout(r, POLL_MS)); await new Promise(r => setTimeout(r, POLL_MS));
let res, r = null; let res, r = null;
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; } try { res = await fetch(pin(org, `/api/agent/request?id=${encodeURIComponent(request)}`), { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' }; if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
try { r = await res.json(); } catch { continue; } try { r = await res.json(); } catch { continue; }
if (res.ok && r?.request === request && r.state === 'closed') return { done: r }; if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
} }
return { stop: `Request ${request} is still open. Read the agents again later.` }; return { stop: `Request ${request} is still open. Read the agents again later.` };
} }
async function runs(name) { async function runs(name, org) {
try { try {
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' }); const res = await fetch(pin(org, '/api/bus/instances'), { cache: 'no-store', redirect: 'manual' });
if (!res.ok || res.type === 'opaqueredirect') return null; if (!res.ok || res.type === 'opaqueredirect') return null;
const e = (await res.json())?.rows?.[0]?.instances?.[name]; const e = (await res.json())?.rows?.[0]?.instances?.[name];
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0; return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
} catch { return null; } } catch { return null; }
} }
async function restartAgent(name, thread) { async function restartAgent(name, thread) {
const org = window.MosaicOrg?.id ?? null;
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` }; const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
const stop = text => { r.busy = false; r.text = text; paintRestart(name); }; const paint = () => paintRestart(name, org);
paintRestart(name); const stop = text => { r.busy = false; r.text = text; paint(); };
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' }); paint();
const asked = await send(pin(org, '/api/agent/terminate'), { instance: name, reason: 'route change' });
if (asked.signedOut) return stop('Sign in again, then retry.'); if (asked.signedOut) return stop('Sign in again, then retry.');
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result)); if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
const ended = await closing(asked.result.request); const ended = await closing(asked.result.request, org);
if (ended.stop) return stop(ended.stop); if (ended.stop) return stop(ended.stop);
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`); if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name); r.text = `Terminated ${name}. Waiting for its session to end…`; paint();
let left = false; let left = false;
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; } for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name, org)) === 0; }
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`); if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`); if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
r.text = `Launching ${name}…`; paintRestart(name); r.text = `Launching ${name}…`; paint();
const launch = await send('/api/agent/launch', { instance: name }); const launch = await send(pin(org, '/api/agent/launch'), { instance: name });
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.'); if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`); if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
const started = await closing(launch.result.request); const started = await closing(launch.result.request, org);
if (started.stop) return stop(started.stop); if (started.stop) return stop(started.stop);
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`); if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`); return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
+8 -1
View File
@@ -552,7 +552,14 @@
document.addEventListener('mosaic-org-switch', onOrgSwitch); document.addEventListener('mosaic-org-switch', onOrgSwitch);
// An address with no organization picks one after the first list read; its own setting applies then. // An address with no organization picks one after the first list read; its own setting applies then.
window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); }); window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); });
window.addEventListener('hashchange', () => { if (window.MosaicOrg) busDown(); }); // The banner hides on the system dashboard and shows on an organization's pages, so a move across
// that line redraws it. Other moves inside one organization read nothing (the switch path reads its own).
let onDashboard = null;
window.addEventListener('hashchange', () => {
if (!window.MosaicOrg) return;
const now = window.MosaicOrg.route().split('?')[0] === '/dashboard';
if (now !== onDashboard) { onDashboard = now; busDown(); }
});
$('refresh').onclick = refresh; $('refresh').onclick = refresh;
$('mobile-pause').onclick = () => $('pause').click(); $('mobile-pause').onclick = () => $('pause').click();
$('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); }; $('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
+46 -28
View File
@@ -345,6 +345,9 @@
let configRead = Promise.resolve(); let configRead = Promise.resolve();
// #1873 SP1: the config read an organization switch started, until the next render has waited for it. // #1873 SP1: the config read an organization switch started, until the next render has waited for it.
let switchRead = null; let switchRead = null;
// #1873 SP1: bumped by each in-place organization switch. A write captures it before its await and
// drops its answer when it changed, so nothing the old organization answered lands on the new page.
let orgEpoch = 0;
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = []; let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap const REPLIES = 50; // the broker's cap
function repliesHtml() { function repliesHtml() {
@@ -465,7 +468,9 @@
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;} if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;}
const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})}; const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})};
hireBusy=true;hireStatus='';hireCode='';paintHireStart(); hireBusy=true;hireStatus='';hireCode='';paintHireStart();
const epoch=orgEpoch;
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})}); const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})});
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
hireBusy=false; hireBusy=false;
if(res?.type==='opaqueredirect'){signOut();return;} if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';} if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';}
@@ -490,7 +495,9 @@
if(fireBusy||!org||out()||!canDecide)return; if(fireBusy||!org||out()||!canDecide)return;
fireBusy=true; fireBusy=true;
for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true; for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true;
const epoch=orgEpoch;
const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}}); const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
fireBusy=false;fireAsk=null; fireBusy=false;fireAsk=null;
if(res?.type==='opaqueredirect'){signOut();return;} if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && json?.op==='resolved'){ if(res?.ok && json?.op==='resolved'){
@@ -1041,9 +1048,9 @@
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form){settingsInputs=settingDraft(form);markSettingsDraft('limits',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('launch-settings-reload');if(discard)discard.hidden=false;}}); document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form){settingsInputs=settingDraft(form);markSettingsDraft('limits',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('launch-settings-reload');if(discard)discard.hidden=false;}});
document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);}); document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);});
document.addEventListener('submit',async e=>{ document.addEventListener('submit',async e=>{
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false); if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}} try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);} catch{if(epoch!==orgEpoch)return;settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(false);}}
}); });
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A
// limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126 // limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126
@@ -1064,9 +1071,9 @@
document.addEventListener('click',async e=>{ document.addEventListener('click',async e=>{
const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return; const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return;
const revision=settingsSnapshot?.revision;if(!/^[a-f0-9]{64}$/.test(revision??''))return; const revision=settingsSnapshot?.revision;if(!/^[a-f0-9]{64}$/.test(revision??''))return;
settingsSaving=true;resetReceipt='';render(false);let reread=false; settingsSaving=true;resetReceipt='';render(false);let reread=false;const epoch=orgEpoch;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}} try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}}
catch{resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{settingsSaving=false;if(!out())render(reread);} catch{if(epoch!==orgEpoch)return;resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(reread);}}
}); });
function tokenUsage(value) { function tokenUsage(value) {
if (!value?.total) return '<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Usage unavailable. No count was read.</p></section>'; if (!value?.total) return '<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Usage unavailable. No count was read.</p></section>';
@@ -1121,9 +1128,9 @@
} }
const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.'; const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.';
async function assistantPost(url,payload,instance,ok,kind='tester'){ async function assistantPost(url,payload,instance,ok,kind='tester'){
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false); if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);const epoch=orgEpoch;
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}} try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
catch{assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);} catch{if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(epoch===orgEpoch){if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}}
} }
const assistantForm=el=>el?.closest?.('.assistant-form'); const assistantForm=el=>el?.closest?.('.assistant-form');
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};if(field){markSettingsDraft('assistant',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-assistant-discard]');if(discard)discard.hidden=false;}}); for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};if(field){markSettingsDraft('assistant',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-assistant-discard]');if(discard)discard.hidden=false;}});
@@ -1188,11 +1195,11 @@
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||!instanceView)return; const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||!instanceView)return;
const op=form.dataset.op;if(instanceBusyOps.has(op))return;instanceReceiptOp=op;instanceDrafts[op]=instanceFields(form);let payload; const op=form.dataset.op;if(instanceBusyOps.has(op))return;instanceReceiptOp=op;instanceDrafts[op]=instanceFields(form);let payload;
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;} try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false); instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;} if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}} else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);} catch{if(epoch!==orgEpoch)return;instanceReceipt=unknownInstance;}finally{if(epoch===orgEpoch){instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}}
}); });
// Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and // Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
// the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the // the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
@@ -1218,8 +1225,8 @@
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:''; const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false; let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){ function readHarnesses(){
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true; if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;const epoch=orgEpoch;
fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);}); fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(epoch!==orgEpoch)return;if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{if(epoch!==orgEpoch)return;harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{if(epoch!==orgEpoch)return;harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
} }
const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']]; const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''}); const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
@@ -1296,32 +1303,32 @@
if(harnessSettings.revision!==routesView?.revision){harnessReceipt='The business changed. Reload this page before saving harnesses.';render(false);return;} if(harnessSettings.revision!==routesView?.revision){harnessReceipt='The business changed. Reload this page before saving harnesses.';render(false);return;}
const used=old.filter(n=>!selected.includes(n) && Object.values(routesView.instances).some(r=>r.harness===n || r.fallback?.harness===n)); const used=old.filter(n=>!selected.includes(n) && Object.values(routesView.instances).some(r=>r.harness===n || r.fallback?.harness===n));
if(used.length){harnessReceipt=`A saved route uses ${used.join(', ')}. Change the route before switching it off.`;render(false);return;} if(used.length){harnessReceipt=`A saved route uses ${used.join(', ')}. Change the route before switching it off.`;render(false);return;}
harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt=''; harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';const epoch=orgEpoch;
try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');} try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(epoch!==orgEpoch)return;if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
catch{harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;} catch{if(epoch!==orgEpoch)return;harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
finally{harnessBusy=false;if(!out())render(false);} finally{if(epoch===orgEpoch){harnessBusy=false;if(!out())render(false);}}
}); });
document.addEventListener('click',async e=>{if(e.target.id!=='provider-switch'||!canSend||isTester)return; document.addEventListener('click',async e=>{if(e.target.id!=='provider-switch'||!canSend||isTester)return;
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true; const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;const epoch=orgEpoch;
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.'); try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(epoch!==orgEpoch)return;if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[]; const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[];
// The fresh read decides: a stale label never turns the proxy module on when it meant off. // The fresh read decides: a stale label never turns the proxy module on when it meant off.
const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;} const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;}
const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy']; const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy'];
const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'}); const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});
if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true); if(epoch!==orgEpoch)return;if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
}catch(error){status.textContent=error.message;button.disabled=false;} }catch(error){if(epoch!==orgEpoch)return;status.textContent=error.message;button.disabled=false;}
}); });
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');}); document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);}); document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{ document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return; if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;} routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
routesBusy=true;routesReceipt='';routesApply=null;render(false); routesBusy=true;routesReceipt='';routesApply=null;render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));} if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;const applied=await routesApplyRead(Object.keys(payload.routes));if(epoch!==orgEpoch)return;routesApply=applied;}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}} else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);} catch{if(epoch!==orgEpoch)return;routesReceipt=unknownRoutes;}finally{if(epoch===orgEpoch){routesBusy=false;if(!out())render(false);}}
}); });
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from // Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
// the same instances read. A refresh drops that read so the next render asks the bus again. // the same instances read. A refresh drops that read so the next render asks the bus again.
@@ -1935,11 +1942,13 @@
if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; } if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; } if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
sending = true; receipt = null; paintSend(); sending = true; receipt = null; paintSend();
const epoch = orgEpoch;
let res = null, body = null; let res = null, body = null;
try { try {
res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' }); res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });
try { body = await res.json(); } catch { body = null; } try { body = await res.json(); } catch { body = null; }
} catch { res = null; } } catch { res = null; }
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
// Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's // Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's
// sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the // sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the
// request never reached the Console. Anything else, no answer included, may have been sent, // request never reached the Console. Anything else, no answer included, may have been sent,
@@ -1999,8 +2008,16 @@
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); }); document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange // #1873 SP1: another organization opened in place (display.js, before this file's hashchange
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config // render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
// is read before the next page draws. A write already sent finishes against the old organization. // is read before the next page draws. A write already sent finishes against the old organization,
// and its answer is not drawn on the new one.
document.addEventListener('mosaic-org-switch', () => { document.addEventListener('mosaic-org-switch', () => {
// Writes still in flight keep going to the old organization; their answers are dropped (orgEpoch)
// and every busy flag and receipt starts clean here.
orgEpoch++;
sending = false; receipt = null; replies = [];
hireBusy = false; fireBusy = false; settingsSaving = false; resetReceipt = '';
assistantBusy = false; assistantFormsBusy.clear(); instanceBusyOps = new Set();
routesBusy = false; routesApply = null; harnessBusy = false; harnessLoading = false;
forget(); clearTimeout(timer); forget(); clearTimeout(timer);
answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null; answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null;
draft.to = ''; draft.body = ''; draft.to = ''; draft.body = '';
@@ -2011,7 +2028,8 @@
instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null); instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null);
routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++; routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++;
harnessSettings = null; harnessDraft = null; harnessReceipt = ''; harnessSettings = null; harnessDraft = null; harnessReceipt = '';
docKey = null; pkAll = []; pkShown = []; pkAt = 0; // A file draft is keyed by project and path only; the page reload that used to drop it is gone.
docKey = null; docStates.clear(); pkAll = []; pkShown = []; pkAt = 0;
configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {}); configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {});
}); });
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } }); document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
+5 -2
View File
@@ -135,9 +135,12 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads. // Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads.
window.addEventListener('hashchange', event => { window.addEventListener('hashchange', event => {
const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous; const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous;
const raw = location.hash, seg = orgOf(raw) ?? current, next = normalize(raw, seg); const raw = location.hash;
// First run (no organization yet) keeps its own addresses: #/ is its board, not #/dashboard.
// Before the first list read settles, the settle step normalizes whatever address it finds.
if (!settled || !current) { previous = raw; return; }
const seg = orgOf(raw) ?? current, next = normalize(raw, seg);
if (next !== raw) history.replaceState(null, '', next); if (next !== raw) history.replaceState(null, '', next);
if (!settled || !current) { previous = next; return; }
const switching = seg !== current; const switching = seg !== current;
if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) { if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
+10 -2
View File
@@ -230,10 +230,18 @@
// SP1: the reason an organization's bus can't be read, for its pages. `row` is its GET /api/workspace // SP1: the reason an organization's bus can't be read, for its pages. `row` is its GET /api/workspace
// status (state, reason, boot), or null when that read gave none. // status (state, reason, boot), or null when that read gave none.
// A reason that only restates the heading ("the bus is not running", the server's fallback) is not
// repeated; the last start time, when read, still shows.
const restates = reason => /^the bus is not (running|answering)\.?$/i.test(reason.trim());
const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : ''); const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : '');
function downBanner(org, row) { function downBanner(org, row) {
const stopped = org.busStopped === true, why = downWhy(row); const stopped = org.busStopped === true;
return `<div class="banner err" role="status" data-org-down><div><b>${stopped ? 'The bus is not running.' : 'The bus is not answering.'}</b> Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${why ? ` <span class="why">Reason: ${esc(why)}.</span>` : ' <span class="why">No reason was read.</span>'}</div></div>`; const plain = row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason && restates(row.reason);
const why = plain ? '' : downWhy(row);
const tail = plain
? (row.boot?.at ? ` <span class="why">Last start ${esc(when(row.boot.at))}.</span>` : '')
: why ? ` <span class="why">Reason: ${esc(why)}.</span>` : ' <span class="why">No reason was read.</span>';
return `<div class="banner err" role="status" data-org-down><div><b>${stopped ? 'The bus is not running.' : 'The bus is not answering.'}</b> Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}</div></div>`;
} }
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus // A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix. // restart (that stays in the organization's full view) and its ids carry the pane's prefix.
@@ -217,15 +217,9 @@ test('organization switch isolates browser board preferences', async t => {
await b.navigate(base + '#/orgs/alpha/'); await b.navigate(base + '#/orgs/alpha/');
await b.evaluate(`document.querySelector('#show-machine').click()`); await b.evaluate(`document.querySelector('#show-machine').click()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true); assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
// The switch reloads the page. The navigate helper's load checks can be in flight when that // #1873 SP1: the switch happens in place, with no reload, and beta's own board setting applies.
// reload commits, so set the hash in the page and poll one short check at a time.
await b.evaluate(`location.hash = '#/orgs/beta/'`); await b.evaluate(`location.hash = '#/orgs/beta/'`);
let loaded = false; await b.evaluate(`(async () => { for (let i = 0; i < 150; i++) { if (window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')) return; await new Promise(r => setTimeout(r, 30)); } throw Error('beta did not load'); })()`);
for (let i = 0; i < 150 && !loaded; i++) {
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
if (!loaded) await new Promise(r => setTimeout(r, 30));
}
assert.equal(loaded, true, 'beta did not load');
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false); assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
}); });
@@ -13,7 +13,7 @@ import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
async function fixture() { async function fixture({ empty = false, bootless = false } = {}) {
const { checkRootAgents } = await import('../../organization/src/index.mjs'); const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url)); const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) }; const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
@@ -35,6 +35,8 @@ async function fixture() {
}, },
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] }, beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
}; };
// Launch settings differ per organization so a value carried from one to the other shows.
const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } });
const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
const opts = id => ({ const opts = id => ({
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') }, bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
@@ -42,19 +44,19 @@ async function fixture() {
}); });
const member = org => ({ const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} }, id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} },
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id), options: () => opts(org.id),
}); });
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }])); const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
const organizations = { const organizations = {
list: async () => LIST, list: async () => (empty ? [] : LIST),
members: async () => LIST.map(member), members: async () => (empty ? [] : LIST.map(member)),
resolve: async id => { resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null; const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return { return {
busHealthy: true, busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) }, bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] }, chat: { projects: async () => DATA[id].projects, threads: async () => [] },
}; };
@@ -63,12 +65,14 @@ async function fixture() {
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } }; return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
} }
async function setup(t, { viewport = [1440, 900], path = '/' } = {}) { async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
const { organizations, agentLevels } = await fixture(); const { organizations, agentLevels } = await fixture({ empty, bootless });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
t.after(() => close(server)); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close()); const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport); await b.viewport(...viewport);
// `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL.
if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init });
const base = `http://127.0.0.1:${server.address().port}`; const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}${path}`); await b.navigate(`${base}${path}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
@@ -120,7 +124,7 @@ test('an organization opens inside the same shell with a breadcrumb, and its pag
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard'); assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null); assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
// The sidebar marks the open organization. // The sidebar marks the open organization.
await wait('document.querySelector("#workspace-nav [aria-current=page]")?.closest("[data-org]")?.dataset.org === "acme" || document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"'); await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
// The sidebar opens another organization in place; its page reads that organization's routes. // The sidebar opens another organization in place; its page reads that organization's routes.
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()'); await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
@@ -172,3 +176,112 @@ test('at 390 px the dashboard lists the organizations without a horizontal scrol
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3'); await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
}); });
// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the
// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for.
test('first run: the wordmark opens the first-run board, not a Not found page', async t => {
const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true });
await wait('document.querySelector("#bus-view h1")');
await b.evaluate('document.querySelector(".wordmark").click()');
// The board route empties the page view; give the router and render time to settle before checking.
await wait('location.hash !== "#/settings/appearance"');
await new Promise(r => setTimeout(r, 800));
assert.equal(await b.evaluate('location.hash'), '#/');
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false);
assert.equal(await b.evaluate('window.MosaicOrg'), undefined);
});
test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => {
const ID = '11111111-2222-4333-8444-555555555555';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `window.__reads = []; window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init });
const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]';
await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`);
await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`);
await wait('window.__reads.length >= 1');
assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']);
assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/);
await b.evaluate('location.hash = "#/orgs/beta/settings/agents"');
await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")');
const seen = await b.evaluate('window.__reads.length');
// Two poll periods pass on beta's page.
await new Promise(r => setTimeout(r, 4500));
const reads = await b.evaluate('window.__reads');
assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' '));
assert.equal(reads.length, seen, 'the poll stopped at the switch');
assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false);
});
test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// acme's settings POST is held until the test releases it, then answers with acme's saved values.
const init = `window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
const submit = '#launch-settings-form button[type=submit]';
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`);
await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`);
await wait('typeof window.__release === "function"');
assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64));
await b.evaluate('location.hash = "#/orgs/beta/settings/limits"');
await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`);
// beta's Save is its own: not held by acme's write.
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
await b.evaluate('window.__release()');
await new Promise(r => setTimeout(r, 700));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7');
assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
});
test('a down banner does not repeat its heading as the reason', async t => {
// No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words.
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true });
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
assert.match(down, /The bus is not running\./);
assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down);
assert.doesNotMatch(down, /Reason:|No reason was read/);
});
test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => {
const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// Every agent request read and write is recorded, whichever organization it names; the bus answers
// are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and
// recorded (the session gone): beta's own page reads beta's instances, which is not the chain.
const init = `window.__chain = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id');
x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; }
if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init });
await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")');
// The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page.
await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()');
await wait('window.__chain.length >= 1');
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('location.hash === "#/orgs/beta/organization"');
// Done when the chain has launched and read the launch request back, or as soon as any of it names beta.
await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000);
const chain = await b.evaluate('window.__chain');
assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' '));
// beta's page draws nothing of acme's chain.
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
});