diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index b0ffec0ce..01c12b9e1 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -262,6 +262,7 @@ // render. That read marks nothing. Row 150 (#1644): since row 142, `mosaic talk` prints only the // reply to its own message, so a reply to a Console send stays unread in `mosaic inbox` until // the human marks it read with `mosaic inbox --read`. + let isTester = false; let canSend = false, sendRoles = null, sending = false, receipt = null, replies = []; const REPLIES = 50; // the broker's cap function repliesHtml() { @@ -687,7 +688,7 @@ const title='

Role instances

'; if(!view)return `
${title}

The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.

`; if(!instanceDrafts.add&&!instanceDrafts.change)instanceView=view; - const v=instanceView, disabled=!canSend||out()||instanceBusy?' disabled':''; + const v=instanceView, disabled=!(canSend || isTester)||out()||instanceBusy?' disabled':''; const defs=Object.entries(v.definitions), accepted=defs.filter(([,d])=>d.accepted).map(([n])=>n); const bound=defs.filter(([,d])=>Array.isArray(d.services)&&d.services.length).map(([n,d])=>`${txt(n)} (${d.services.map(txt).join(', ')})`); const changeable=Object.entries(v.instances).filter(([,e])=>!e.protected).map(([n])=>n); @@ -698,7 +699,7 @@ const changeLaunch=v.launch?``:''; const addForm=`
Add an instance
${addLaunch}
`; const changeForm=changeable.length?`
Change an instance
${changeLaunch}
`:''; - return `
${title}

Add an instance or change one's definition, vars and launch membership. Holder, credentials and tracker stay hand edits, and so does an instance of a definition that declares credentials. An assistant changes in the Assistant section.

${rows}
InstanceDefinitionLaunchedVars
${addForm}${changeForm}${bound.length?`

Definitions that declare credentials, a hand edit: ${bound.join(', ')}.

`:''}

${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}

${txt(instanceReceipt)}

`; + return `
${title}

Add an instance or change one's definition, vars and launch membership. Holder, credentials and tracker stay hand edits, and so does an instance of a definition that declares credentials. An assistant changes in the Assistant section.

${rows}
InstanceDefinitionLaunchedVars
${addForm}${changeForm}${bound.length?`

Definitions that declare credentials, a hand edit: ${bound.join(', ')}.

`:''}

${canSend || isTester?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}

${txt(instanceReceipt)}

`; } const instanceFields=form=>Object.fromEntries([...form.querySelectorAll('[data-instance-field]')].map(el=>[el.dataset.instanceField,el.type==='checkbox'?el.checked:el.value])); function instancePayload(op,f){ @@ -718,7 +719,7 @@ for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form)instanceDrafts[form.dataset.op]=instanceFields(form);}); document.addEventListener('click',e=>{if(e.target.id!=='instances-reload')return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';last.delete('instances');render(true);}); document.addEventListener('submit',async e=>{ - const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||instanceBusy||!instanceView)return; + const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||instanceBusy||!instanceView)return; const op=form.dataset.op;instanceDrafts[op]=instanceFields(form);let payload; try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;render(false);return;} instanceBusy=true;instanceReceipt='';render(false); @@ -741,7 +742,7 @@ const title='

Routing

'; if(!view)return `
${title}

The routes could not be read, so there is nothing to change. Read again to retry; they come from the bus host with your identity.

`; if(!routesDraft)routesView=view; - const v=routesView, disabled=!canSend||out()||routesBusy?' disabled':''; + const v=routesView, disabled=!(canSend || isTester)||out()||routesBusy?' disabled':''; const cells=name=>routesDraft?.[name]??routeCells(v.instances[name]); const select=(name,key,list,current,why)=>{const offered=current===''||list.includes(current)?list:[...list,current]; return ``;}; @@ -752,7 +753,7 @@ const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness'); return `${txt(name)}${harness('harness')}${model('model')}${thinking('thinking','harness')}${harness('fallback.harness')}${model('fallback.model')}${thinking('fallback.thinking','fallback.harness')}`;}).join(''); const off=v.harnesses.length?'':'

No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. mosaic plugins set switches one on.

'; - return `
${title}

Each instance's harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route; the next launch reads the change.

${off}
${ROUTE_CELLS.map(([,l])=>``).join('')}${rows}
Instance${l}

${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}

${txt(routesReceipt)}

`; + return `
${title}

Each instance's harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route; the next launch reads the change.

${off}
${ROUTE_CELLS.map(([,l])=>``).join('')}${rows}
Instance${l}

${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}

${txt(routesReceipt)}

`; } function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;} // The changed rows only. A fallback with all three cells empty is cleared; one with some set goes @@ -772,7 +773,7 @@ if(kind==='change'&&/harness"\]$/.test(e.target.dataset?.route??''))render(false);}); document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);}); document.addEventListener('submit',async e=>{ - if(e.target.id!=='routes-form')return;e.preventDefault();if(!canSend||out()||routesBusy||!routesView)return; + if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return; routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;} routesBusy=true;routesReceipt='';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(); @@ -780,6 +781,28 @@ else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}} catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);} }); + // Tester management is deliberately separate from the human's assistant/settings forms. + function testerNames(names, instances) { + if (!instances || !names) return ''; + const current = Object.fromEntries(names.map(row => [row.instance, row.name])); + return `

Seat names

${Object.keys(instances.instances).map(instance => `

${txt(assistantReceipts[instance] ?? '')}

`).join('')}
`; + } + document.addEventListener('input', event => { + const form = event.target.closest?.('.tester-name-form'); + if (form) nameDrafts[form.dataset.instance] = event.target.value; + }); + document.addEventListener('submit', async event => { + const form = event.target.closest?.('.tester-name-form'); + if (!form || event.target !== form) return; + event.preventDefault(); + if (!isTester || assistantBusy || out()) return; + const instance = form.dataset.instance, name = form.elements.name.value.trim(); + await assistantPost('/api/name', { instance, name }, instance, result => { + if (typeof result?.name !== 'string') return unknownAssistant; + delete nameDrafts[instance]; last.delete('names'); + return `Seat name set to ${result.name}.`; + }); + }); async function settingsView(get) { const s = await get.api('settings'), n = s.notifier || {}; let usage, names = null; @@ -791,6 +814,7 @@ try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } let limitRows = [], limitsRefused = false; try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } + if (isTester) return `${head('Settings', `${readLine()} Tester management.`)}${routesSection(routes)}${instancesSection(instances)}${testerNames(names, instances)}`; const notify = typeof n.binding === 'string' ? `
Binding
${txt(n.binding)}

The binding name only. Its channels, server and user ids stay in the binding file.

` : empty('No notifier binding to show.', `${n.refused ? `${txt(n.refused)}: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`); @@ -966,6 +990,7 @@ const retry = '
'; // Why a read failed, in words, with the bus code as given. Never a path or a guess. function why(err) { + if (err.code === 'tester-off' || err.code === 'tester-required') return 'The tester is off or this request did not bind the tester. Nothing was changed.'; const code = `${txt(err.code)}`; // The queue, business and settings reads: the module's own text, redacted by the server. if (err.module) { @@ -1259,9 +1284,15 @@ // Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's // Message buttons are drawn from the roles already read. fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => { - canEdit = c?.edit === true; // row 148: implies send - if (c?.send !== true) return; - canSend = true; + isTester = c?.tester === true; + canEdit = !isTester && c?.edit === true; + canSend = !isTester && c?.send === true; + if (isTester) { + const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref'; + banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.'; + $('main').prepend(banner); + } + if (!canSend && !isTester) return; const path = routeOf().split('?')[0]; if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); }).catch(() => {});