`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){
- if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
- 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);});
+ 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(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 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??''});
@@ -1293,32 +1304,32 @@
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));
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='';
- 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');}
- catch{harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
- finally{harnessBusy=false;if(!out())render(false);}
+ 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(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{if(epoch!==orgEpoch)return;harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=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;
- const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;
- 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.');
+ 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(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']:[];
// 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 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'});
- 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;}
+ if(epoch!==orgEpoch)return;if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
+ }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.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
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;}
- routesBusy=true;routesReceipt='';routesApply=null;render(false);
- 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();
- 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));}
+ 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(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.`;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.`;}}
- 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
// the same instances read. A refresh drops that read so the next render asks the bus again.
@@ -1529,6 +1540,7 @@
if (!s || !saveable(s)) return;
const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.';
s.saving = true; s.status = '';
+ const epoch = orgEpoch;
let res = null, body = null;
try {
paintDoc();
@@ -1540,13 +1552,16 @@
} else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`;
else s.status = unknown;
} finally { s.saving = false; } // a throw above must not leave Save off and Reload refused
+ // #1873 SP1: a switch while this was in flight cleared the drafts; the old file's outcome is not
+ // painted or announced on the new organization's page.
+ if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
paintDoc();
announce(s.status);
if (res?.type === 'opaqueredirect') signOut();
});
// Not found and states
- function notFound(why) { return `${head('Not found')}
`;
@@ -227,6 +228,21 @@
return Boolean(a && a.el.isConnected);
}
+ // 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.
+ // 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)}` : ''}` : '');
+ function downBanner(org, row) {
+ const stopped = org.busStopped === true;
+ 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 ? ` Last start ${esc(when(row.boot.at))}.` : '')
+ : why ? ` Reason: ${esc(why)}.` : ' No reason was read.';
+ return `
${stopped ? 'The bus is not running.' : 'The bus is not answering.'} Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}
`;
+ }
// 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.
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
@@ -244,6 +260,8 @@
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
+ // SP1: a bus that is down says so above everything else, with the reason the workspace read gives.
+ const downRow = org.busHealthy ? null : await data.workspace({ org: id, sections: 'projects' }).then(b => arr(b?.orgs).find(r => r?.org === id) ?? null).catch(() => null);
await window.MosaicConsole?.ready;
const name = esc(label(org));
const tracker = business?.business?.vars?.['tracker.kind'];
@@ -290,8 +308,9 @@
const stoppedPanel = (attr, title) => `
@@ -320,6 +339,8 @@
// only). Every status comes from the organization's bus: its instances, and since A3 the root and
// project agent copies it booted with. Nothing here invents a status, a model or a memory.
const NOT_RUNNING = ['s-idle', '–', 'not running'];
+ // SP1: why a bus-backed read gave nothing, in the membership list's words.
+ const busWord = org => (org?.busStopped === true ? 'the bus is stopped' : 'the bus is not answering');
// A copy's status: running or not as the bus reports its runs, or why it can't launch.
const CANT = ['s-error', '▲', "can't launch"];
// #1873 CB5: the bus adds `binding` to a copy that needs a credential binding: its status code and
@@ -364,7 +385,7 @@
} else if (inst) {
rows.push(...Object.entries(inst).map(([name, v]) => ({ name, level: 'org', definition: v?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] })));
}
- if (!set && inst === null) return '
Agents unavailable. The bus did not answer the instance read.
';
+ if (!set && inst === null) return `
${selOrg?.busHealthy === false ? `Agents not read: ${busWord(selOrg)}.` : 'Agents unavailable. The bus did not answer the instance read.'}
`;
if (!rows.length) return '
No agents configured
';
const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`);
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
@@ -448,7 +469,7 @@
const on = kept.filter(a => !own(a)), replaced = kept.filter(own);
return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || 'none'}${replaced.length ? `${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own` : ''}${off.length ? `${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}` : ''}`, endc(i));
}).join('');
- const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? 'Agents unavailable' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(org, k, v, agents)).join('') : 'No agents configured', endc(i))).join('');
+ const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? `${org.busHealthy === false ? `Agents not read: ${busWord(org)}` : 'Agents unavailable'}` : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(org, k, v, agents)).join('') : 'No agents configured', endc(i))).join('');
// Project: the agents a project defines itself. Narrowing and exclusion show in the table.
const projRow = results.map(({ org, lv, rows, copies, agents }, i) => {
if (lv?.state !== 'ok') return cell(org, `${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}`, ` last${endc(i)}`);
@@ -549,7 +570,11 @@
// ---- main dashboard (sample 02) ----
async function home() {
let body;
- try { [body] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), ensureThemes()]); took(body); }
+ let levels = null, listed = null;
+ // SP1: the agent counts come from the organization files (GET /api/agents/levels, owner only), so a
+ // stopped bus still has one; the bus state words come from the membership list. Either read may
+ // fail or be absent, and its column then shows no figure.
+ try { [body, levels, listed] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), data.agentLevels().catch(() => null), data.organizations().catch(() => null), ensureThemes()]); took(body); }
catch (err) { return failedPage('ws-home', 'data-ws-home', 'Dashboard', err); }
const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs);
const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks);
@@ -573,11 +598,27 @@
Model spendNo spend figures yetNo model prices are read yet, so today's use has no cost figure.
` : '
No organization answered, so there are no totals yet.