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:
1 parent
e24e7e93a2
commit
bb2f79f3d9
7 files changed
+226
-64
No files matched your search
@@ -76,8 +76,11 @@
|
||||
if (!usable() || busy || pending || !view) return;
|
||||
const [url, payload] = plan(action, name);
|
||||
busy = true; confirming = null; receipt = ''; redraw();
|
||||
const gen = generation;
|
||||
let answer;
|
||||
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;
|
||||
if (answer === null) return;
|
||||
const { ok, result } = answer;
|
||||
@@ -122,8 +125,10 @@
|
||||
const instance = create.instance.trim(), definition = create.definition;
|
||||
if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; }
|
||||
busy = true; receipt = ''; redraw();
|
||||
const gen = generation;
|
||||
let answer;
|
||||
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;
|
||||
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(); }
|
||||
@@ -154,19 +159,31 @@
|
||||
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.
|
||||
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
|
||||
// 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.
|
||||
// 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;
|
||||
function restartMarkup(name, thread = false) {
|
||||
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>`;
|
||||
}
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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]');
|
||||
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
|
||||
});
|
||||
@@ -179,45 +196,47 @@
|
||||
return { ok: res.ok, result };
|
||||
}
|
||||
// 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++) {
|
||||
await new Promise(r => setTimeout(r, POLL_MS));
|
||||
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.' };
|
||||
try { r = await res.json(); } catch { continue; }
|
||||
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
|
||||
}
|
||||
return { stop: `Request ${request} is still open. Read the agents again later.` };
|
||||
}
|
||||
async function runs(name) {
|
||||
async function runs(name, org) {
|
||||
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;
|
||||
const e = (await res.json())?.rows?.[0]?.instances?.[name];
|
||||
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function restartAgent(name, thread) {
|
||||
const org = window.MosaicOrg?.id ?? null;
|
||||
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
|
||||
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
|
||||
paintRestart(name);
|
||||
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
|
||||
const paint = () => paintRestart(name, org);
|
||||
const stop = text => { r.busy = false; r.text = text; paint(); };
|
||||
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.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.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;
|
||||
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 (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
|
||||
r.text = `Launching ${name}…`; paintRestart(name);
|
||||
const launch = await send('/api/agent/launch', { instance: name });
|
||||
r.text = `Launching ${name}…`; paint();
|
||||
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.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.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})` : ''}.`);
|
||||
|
||||
@@ -552,7 +552,14 @@
|
||||
document.addEventListener('mosaic-org-switch', onOrgSwitch);
|
||||
// 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.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;
|
||||
$('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(); };
|
||||
|
||||
@@ -345,6 +345,9 @@
|
||||
let configRead = Promise.resolve();
|
||||
// #1873 SP1: the config read an organization switch started, until the next render has waited for it.
|
||||
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 = [];
|
||||
const REPLIES = 50; // the broker's cap
|
||||
function repliesHtml() {
|
||||
@@ -465,7 +468,9 @@
|
||||
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}:{})}})};
|
||||
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})});
|
||||
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
|
||||
hireBusy=false;
|
||||
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.';}
|
||||
@@ -490,7 +495,9 @@
|
||||
if(fireBusy||!org||out()||!canDecide)return;
|
||||
fireBusy=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}});
|
||||
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
|
||||
fireBusy=false;fireAsk=null;
|
||||
if(res?.type==='opaqueredirect'){signOut();return;}
|
||||
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('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=>{
|
||||
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);
|
||||
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.`;}}
|
||||
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())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(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{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
|
||||
// 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=>{
|
||||
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;
|
||||
settingsSaving=true;resetReceipt='';render(false);let reread=false;
|
||||
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.'}`;}}
|
||||
catch{resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{settingsSaving=false;if(!out())render(reread);}
|
||||
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(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{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) {
|
||||
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.';
|
||||
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);
|
||||
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.`;}}
|
||||
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);}
|
||||
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(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{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');
|
||||
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 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;}
|
||||
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
|
||||
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();
|
||||
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(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}`;}
|
||||
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
|
||||
// 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>`:'';
|
||||
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??''});
|
||||
@@ -1296,32 +1303,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.
|
||||
@@ -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 (!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();
|
||||
const epoch = orgEpoch;
|
||||
let res = null, body = null;
|
||||
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' });
|
||||
try { body = await res.json(); } catch { body = 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: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,
|
||||
@@ -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); });
|
||||
// #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
|
||||
// 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', () => {
|
||||
// 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);
|
||||
answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null;
|
||||
draft.to = ''; draft.body = '';
|
||||
@@ -2011,7 +2028,8 @@
|
||||
instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null);
|
||||
routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++;
|
||||
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(() => {});
|
||||
});
|
||||
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
|
||||
|
||||
@@ -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.
|
||||
window.addEventListener('hashchange', event => {
|
||||
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 (!settled || !current) { previous = next; return; }
|
||||
const switching = seg !== current;
|
||||
if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
@@ -230,10 +230,18 @@
|
||||
|
||||
// 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, why = downWhy(row);
|
||||
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 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 ? ` <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
|
||||
// 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.evaluate(`document.querySelector('#show-machine').click()`);
|
||||
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
|
||||
// reload commits, so set the hash in the page and poll one short check at a time.
|
||||
// #1873 SP1: the switch happens in place, with no reload, and beta's own board setting applies.
|
||||
await b.evaluate(`location.hash = '#/orgs/beta/'`);
|
||||
let loaded = false;
|
||||
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');
|
||||
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'); })()`);
|
||||
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 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 rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
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: [] },
|
||||
};
|
||||
// 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 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') },
|
||||
@@ -42,19 +44,19 @@ async function fixture() {
|
||||
});
|
||||
const member = org => ({
|
||||
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),
|
||||
});
|
||||
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 = {
|
||||
list: async () => LIST,
|
||||
members: async () => LIST.map(member),
|
||||
list: async () => (empty ? [] : LIST),
|
||||
members: async () => (empty ? [] : LIST.map(member)),
|
||||
resolve: async id => {
|
||||
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 () => [] } };
|
||||
return {
|
||||
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) }) },
|
||||
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' } } };
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], path = '/' } = {}) {
|
||||
const { organizations, agentLevels } = await fixture();
|
||||
async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
|
||||
const { organizations, agentLevels } = await fixture({ empty, bootless });
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
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}`;
|
||||
await b.navigate(`${base}${path}`);
|
||||
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.getElementById("current-organization")'), null);
|
||||
// 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.
|
||||
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');
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user