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.
77 lines
11 KiB
Diff
77 lines
11 KiB
Diff
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
|
|
index 1e0be849d..67abd41f6 100644
|
|
--- a/packages/webui/src/public/bus.js
|
|
+++ b/packages/webui/src/public/bus.js
|
|
@@ -671,17 +671,70 @@
|
|
await assistantPost('/api/name',{instance,name},instance,result=>{if(typeof result?.name!=='string')return unknownAssistant;delete nameDrafts[instance];const prior=last.get('names');if(prior)last.set('names',{...prior,rows:[...prior.rows.filter(r=>r.instance!==result.instance),{instance:result.instance,name:result.name,at:result.at}]});return `Seat name set to ${result.name}.`;});}
|
|
else if(e.target.dataset.assistantStop!==undefined)await assistantPost('/api/assistant-stop',{instance},instance,result=>Array.isArray(result?.stopped)?`Stopped ${plural(result.stopped.length,'run')}.`:unknownAssistant);
|
|
});
|
|
+ // Row 165 (#1662, Q171): role instances. Add one, or change one's definition, vars and launch
|
|
+ // membership, with the read's revision. A definition that declares credentials is named but not
|
|
+ // offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt
|
|
+ // says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries.
|
|
+ let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusy=false, instanceReceipt='';
|
|
+ const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
|
|
+ const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.';
|
|
+ function instancesSection(view){
|
|
+ const title='<h2 id="instances-title">Role instances</h2>';
|
|
+ if(!view)return `<section class="panel" aria-labelledby="instances-title">${title}${empty('The role instances could not be read.','They are read from the bus host with your identity.')}</section>`;
|
|
+ if(!instanceDrafts.add&&!instanceDrafts.change)instanceView=view;
|
|
+ const v=instanceView, disabled=!canSend||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);
|
|
+ const rows=Object.entries(v.instances).map(([name,e])=>`<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launcher?'launcher':e.launched?'yes':'no'}</td><td>${e.vars&&Object.keys(e.vars).length?`<code>${txt(JSON.stringify(e.vars))}</code>`:'<span class="muted">none</span>'}</td></tr>`).join('');
|
|
+ const opts=(list,sel,keep)=>`${keep?`<option value="">${keep}</option>`:''}${list.map(n=>`<option value="${esc(n)}"${n===sel?' selected':''}>${txt(n)}</option>`).join('')}`;
|
|
+ const a=instanceDrafts.add||{}, c=instanceDrafts.change||{};
|
|
+ const addLaunch=v.launch?`<label><input type="checkbox" data-instance-field="launch"${a.launch?' checked':''}${disabled}> Launch it</label>`:'';
|
|
+ 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>`;
|
|
+ }
|
|
+ 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){
|
|
+ const payload={revision:instanceView.revision,instance:String(f.instance??'').trim(),op};
|
|
+ if(!payload.instance)throw Error('Name the instance.');
|
|
+ if(f.definition)payload.definition=f.definition;
|
|
+ if(op==='add'&&!payload.definition)throw Error('Choose a definition.');
|
|
+ if(String(f.vars??'').trim()!==''){let parsed;try{parsed=JSON.parse(f.vars);}catch{throw Error('Vars must be JSON.');}if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Vars must be a JSON object.');payload.vars=parsed;}
|
|
+ if(op==='add'&&f.launch===true)payload.launch=true;
|
|
+ if(op==='change'&&(f.launch==='yes'||f.launch==='no'))payload.launch=f.launch==='yes';
|
|
+ if(op==='change'&&!['definition','vars','launch'].some(k=>Object.hasOwn(payload,k)))throw Error('Nothing changed.');
|
|
+ return payload;
|
|
+ }
|
|
+ const unknownInstance='Outcome unknown. Read the role instances again before trying. Draft kept.';
|
|
+ // The answer replaces the view the next render draws, so it must have the read's whole shape.
|
|
+ const instanceAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&typeof r.restart==='boolean'&&[r.instances,r.definitions].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
|
|
+ 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 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);
|
|
+ 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();
|
|
+ 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?RESTART_TEXT:NO_RESTART_TEXT}`;}
|
|
+ else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
|
|
+ catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);}
|
|
+ });
|
|
async function settingsView(get) {
|
|
const s = await get.api('settings'), n = s.notifier || {};
|
|
let usage, names = null;
|
|
try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
|
|
try { names = await get('names'); } catch { /* the section says the name could not be read */ }
|
|
+ let instances = null;
|
|
+ try { [instances] = await get('instances'); } 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; }
|
|
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')}`);
|
|
- return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
|
+ return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
|
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
|
|
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
|
|
}
|