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.
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.
Instance
Definition
Launched
Vars
${rows}
${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.
Instance
Definition
Launched
Vars
${rows}
${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.'}
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}`;
+ 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}`;
}
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 => ``).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(() => {});