diff --git a/packages/webui/src/public/agents.js b/packages/webui/src/public/agents.js index 32ebdc554..b7e5fae37 100644 --- a/packages/webui/src/public/agents.js +++ b/packages/webui/src/public/agents.js @@ -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 ` ${esc(r?.text ?? '')}`; } 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})` : ''}.`); diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 01a7d01ab..30992510a 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -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(); }; diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index ccb877ee2..10a1db894 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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 '

Token usage

Usage unavailable. No count was read.

'; @@ -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?``:''; 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); } }); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 880c07d00..459eaf141 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -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(); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index b5dc943b8..bfe978cba 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -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 ``; + 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 ``; } // 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. diff --git a/packages/webui/tests/org-switch-browser.test.mjs b/packages/webui/tests/org-switch-browser.test.mjs index 036e8ce19..64dde0657 100644 --- a/packages/webui/tests/org-switch-browser.test.mjs +++ b/packages/webui/tests/org-switch-browser.test.mjs @@ -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); }); diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs index 29db7545d..40e01b623 100644 --- a/packages/webui/tests/single-pane-browser.test.mjs +++ b/packages/webui/tests/single-pane-browser.test.mjs @@ -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); +});