Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
131 lines
16 KiB
Diff
131 lines
16 KiB
Diff
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='<h2 id="instances-title">Role instances</h2>';
|
|
if(!view)return `<section class="panel" aria-labelledby="instances-title">${title}<p class="small warn">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.</p></section>`;
|
|
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?`<label>Launch <select data-instance-field="launch"${disabled}>${[['','Keep'],['yes','Launch it'],['no','Don\'t launch it']].map(([x,l])=>`<option value="${x}"${(c.launch??'')===x?' selected':''}>${l}</option>`).join('')}</select></label>`:'';
|
|
const addForm=`<form id="instance-add-form" class="instance-form" data-op="add"><fieldset><legend>Add an instance</legend><div class="filters"><label>Instance <input data-instance-field="instance" value="${esc(a.instance??'')}" maxlength="64"${disabled}></label><label>Definition <select data-instance-field="definition"${disabled}>${opts(accepted,a.definition)}</select></label>${addLaunch}</div><label>Vars (JSON, empty for none) <textarea data-instance-field="vars" rows="3"${disabled}>${esc(a.vars??'')}</textarea></label><button type="submit"${disabled}>Add instance</button></fieldset></form>`;
|
|
const changeForm=changeable.length?`<form id="instance-change-form" class="instance-form" data-op="change"><fieldset><legend>Change an instance</legend><div class="filters"><label>Instance <select data-instance-field="instance"${disabled}>${opts(changeable,c.instance)}</select></label><label>Definition <select data-instance-field="definition"${disabled}>${opts(accepted,c.definition,'Keep the definition')}</select></label>${changeLaunch}</div><label>Vars (JSON, replaces them whole; empty keeps them) <textarea data-instance-field="vars" rows="3"${disabled}>${esc(c.vars??'')}</textarea></label><button type="submit"${disabled}>Save instance</button></fieldset></form>`:'';
|
|
- return `<section class="panel" aria-labelledby="instances-title">${title}<p>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.</p><div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table id="instances-table"><thead><tr><th scope="col">Instance</th><th scope="col">Definition</th><th scope="col">Launched</th><th scope="col">Vars</th></tr></thead><tbody>${rows}</tbody></table></div>${addForm}${changeForm}${bound.length?`<p class="small muted">Definitions that declare credentials, a hand edit: ${bound.join(', ')}.</p>`:''}<p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}</p><button type="button" id="instances-reload">Read current instances and discard drafts</button><p role="status" id="instances-receipt">${txt(instanceReceipt)}</p></section>`;
|
|
+ return `<section class="panel" aria-labelledby="instances-title">${title}<p>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.</p><div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table id="instances-table"><thead><tr><th scope="col">Instance</th><th scope="col">Definition</th><th scope="col">Launched</th><th scope="col">Vars</th></tr></thead><tbody>${rows}</tbody></table></div>${addForm}${changeForm}${bound.length?`<p class="small muted">Definitions that declare credentials, a hand edit: ${bound.join(', ')}.</p>`:''}<p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}</p><button type="button" id="instances-reload">Read current instances and discard drafts</button><p role="status" id="instances-receipt">${txt(instanceReceipt)}</p></section>`;
|
|
}
|
|
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='<h2 id="routes-title">Routing</h2>';
|
|
if(!view)return `<section class="panel" aria-labelledby="routes-title">${title}<p class="small warn">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.</p></section>`;
|
|
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 `<select data-route="${esc(inputKey([name,key]))}" aria-label="${esc(`${name} ${ROUTE_CELLS.find(([k])=>k===key)[1].toLowerCase()}`)}"${disabled}>${key.startsWith('fallback.')||current===''?`<option value=""${current===''?' selected':''}>${key.startsWith('fallback.')?'none':'unset'}</option>`:''}${offered.map(x=>`<option value="${esc(x)}"${x===current?' selected':''}>${txt(x)}${list.includes(x)?'':` (${why})`}</option>`).join('')}</select>`;};
|
|
@@ -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 `<tr><th scope="row">${txt(name)}</th><td>${harness('harness')}</td><td>${model('model')}</td><td>${thinking('thinking','harness')}</td><td>${harness('fallback.harness')}</td><td>${model('fallback.model')}</td><td>${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
|
|
const off=v.harnesses.length?'':'<p class="small warn">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. <code>mosaic plugins set</code> switches one on.</p>';
|
|
- return `<section class="panel" aria-labelledby="routes-title">${title}<p>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.</p>${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col">Instance</th>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${routesBusy?'Saving…':'Save routes'}</button> <button type="button" id="routes-reload">Read current routes and discard draft</button><p role="status" id="routes-receipt">${txt(routesReceipt)}</p></form></section>`;
|
|
+ return `<section class="panel" aria-labelledby="routes-title">${title}<p>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.</p>${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col">Instance</th>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><button type="submit"${disabled}>${routesBusy?'Saving…':'Save routes'}</button> <button type="button" id="routes-reload">Read current routes and discard draft</button><p role="status" id="routes-receipt">${txt(routesReceipt)}</p></form></section>`;
|
|
}
|
|
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 `<section class="panel" aria-labelledby="tester-names-title"><h2 id="tester-names-title">Seat names</h2>${Object.keys(instances.instances).map(instance => `<form class="tester-name-form" data-instance="${esc(instance)}"><label>${txt(instance)} <input name="name" maxlength="64" value="${esc(nameDrafts[instance] ?? current[instance] ?? '')}"${assistantBusy || out() ? ' disabled' : ''}></label><button type="submit"${assistantBusy || out() ? ' disabled' : ''}>Set name</button><p role="status">${txt(assistantReceipts[instance] ?? '')}</p></form>`).join('')}</section>`;
|
|
+ }
|
|
+ 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'
|
|
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
|
|
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
|
|
@@ -966,6 +990,7 @@
|
|
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
|
|
// 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 = `<code>${txt(err.code)}</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(() => {});
|