feat(webui): one Agents page for the whole system, Fire pinned to the row's organization (#1873) #1968

Merged
jason.woltje merged 3 commits from sp5-agents into refactor 2026-11-15 09:53:21 +00:00
8 changed files with 467 additions and 37 deletions

No files matched your search

+56 -26
View File
@@ -488,46 +488,65 @@
const FIRE_REASON='Fired from the Agents page.';
let fireAsk=null,fireBusy=false,fireReceipt=null;
const fireKey=(org,name)=>`${org}\u0000${name}`;
function fireNote(){const org=hireOrg();const r=fireReceipt&&fireReceipt.org===org?fireReceipt:null;
// #1873 SP5: the system Agents page lists every organization's agents, so its controls carry their
// organization (data-agent-fire-org) and post to that organization's own bus, never to the one
// opened last. Its receipt box is `data-agents-receipt="*"` and shows a receipt from any of them.
// The agent cards keep the old shape: no attribute, the opened organization, canDecide.
function fireNote(scope=hireOrg()){const r=fireReceipt&&(scope==='*'?fireReceipt.system:fireReceipt.org===scope)?fireReceipt:null;
return r?`<p class="receipt${r.failed?' failed':''}" data-agents-receipt-text>${txt(r.text)}${r.code?` (<code>${txt(r.code)}</code>)`:''}</p>`:'';}
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote();}
async function fireAgent(name){
const org=hireOrg();
if(fireBusy||!org||out()||!canDecide)return;
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote(e.dataset.agentsReceipt||hireOrg());}
const fireSel=(attr,name,org)=>`[${attr}="${CSS.escape(name)}"]${org?`[data-agent-fire-org="${CSS.escape(org)}"]`:':not([data-agent-fire-org])'}`;
async function fireAgent(name,named=null,orgName=named){
const org=named??hireOrg();
// A named organization was checked against its own config when the control was drawn (decidesIn).
if(fireBusy||!org||out()||(!named&&!canDecide)||isTester)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;
const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
// SP5 review F7: a named organization's Fire went to that organization, so its outcome still
// stands after the opened organization changes.
// After a switch, busy and confirm state belong to whatever Fire started since; leave them alone.
const stale=epoch!==orgEpoch;
if(stale&&!named){if(res?.type==='opaqueredirect')signOut();return;}
if(!stale){fireBusy=false;fireAsk=null;}
if(res?.type==='opaqueredirect'){signOut();return;}
const where=named?` in ${orgName}`:'';
if(res?.ok && json?.op==='resolved'){
for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove();
for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove();
fireReceipt={org,text:`Fired ${name}. Recorded decision ${json.id}.`};
for(const r of document.querySelectorAll(`[data-ws-agent="${CSS.escape(`${org}:${name}`)}"]`))r.remove();
fireReceipt={org,system:!!named,text:`Fired ${name}${where}. Recorded decision ${json.id}.`};
paintFireNote();announce(fireReceipt.text);
document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org}}));
} else {
const r=decideRefusal(res,json,'outcome-unknown');
fireReceipt={org,failed:true,text:`${name} was not fired: ${r.message}`,code:r.code};
fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code};
paintFireNote();
for(const c of document.querySelectorAll(`[data-agent-fire-confirm="${CSS.escape(name)}"]`))c.outerHTML=fireButton(name);
for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named,orgName);
}
}
const fireButton=name=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}" data-focus-key="${esc(`fire:${name}`)}" aria-label="Fire ${esc(name)}">Fire</button>`;
const fireConfirm=name=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}?" data-agent-fire-confirm="${esc(name)}">Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}" data-focus-key="${esc(`fire-yes:${name}`)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireBusy?' disabled':''}>Cancel</button></span>`;
// What the agent card shows for one agent: nothing without the decide right or outside the page's own organization.
function fireControl(org,name){
if(!canDecide||isTester||org!==hireOrg())return '';
return fireAsk===fireKey(org,name)?fireConfirm(name):fireButton(name);
const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:'';
const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`);
// SP5 review F5: the words name the organization by its label (`orgName`); its id addresses the control.
const fireNameAttr=(org,orgName)=>org&&orgName&&orgName!==org?` data-agent-fire-name="${esc(orgName)}"`:'';
const fireButton=(name,org=null,orgName=org)=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)} data-focus-key="${fireFocus('fire',name,org)}" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}">Fire</button>`;
const fireConfirm=(name,org=null,orgName=org)=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}?" data-agent-fire-confirm="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)}>Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}"${fireOrgAttr(org)} data-focus-key="${fireFocus('fire-yes',name,org)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireOrgAttr(org)}${fireBusy?' disabled':''}>Cancel</button></span>`;
// What the agent card shows for one agent: nothing without the decide right or outside the page's own
// organization. SP5: `allowed` given means the caller read that organization's own config (decidesIn);
// the control is then drawn for that organization, whichever one was opened last.
function fireControl(org,name,allowed,orgName=org){
const named=typeof allowed==='boolean';
if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return '';
return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null,named?orgName:null):fireButton(name,named?org:null,named?orgName:null);
}
document.addEventListener('click',e=>{
const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]');
if(!t)return;
const org=hireOrg();
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name);document.querySelector(`[data-agent-fire-yes="${CSS.escape(name)}"]`)?.focus();}
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name);announce('Cancelled. Nothing was fired.');}
else void fireAgent(t.dataset.agentFireYes);
const named=t.dataset.agentFireOrg??null,org=named??hireOrg(),orgName=t.closest('[data-agent-fire-name]')?.dataset.agentFireName??named;
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named,orgName);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();}
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named,orgName);document.querySelector(fireSel('data-agent-fire',name,named))?.focus();announce('Cancelled. Nothing was fired.');}
else void fireAgent(t.dataset.agentFireYes,named,orgName);
});
window.MosaicDecide=Object.freeze({fireControl,fireNote});
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
@@ -876,12 +895,15 @@
return `<section aria-labelledby="b-thread-claims">${title}<div class="banner err" role="alert"><p>${why(err)}</p>${retry}</div></section>`;
}
}
async function agentsView(get) {
// SP5: an organization's scoped page leads with its rows of the system Agents table (which owns the
// heading), then the levels grid under its own h2, then held roles and threads.
const scopedTop = async (q, org, note) => `${await window.MosaicWorkspace.agentsPage(q, { scope: org })}${await window.MosaicWorkspace.agentGrid(note, undefined, { under: true })}`;
async function agentsView(get, q = new URLSearchParams(), org = null) {
let claims;
try { claims = await get('agents'); } catch (err) {
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
if (refusal(err)) forget();
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
return `${org ? await scopedTop(q, org) : await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
}
let instances = null, threads = [];
if (!isTester) {
@@ -898,7 +920,7 @@
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
const top = !window.MosaicOrg ? head('Agents', readNote(get)) : org ? await scopedTop(q, org, readNote(get)) : await window.MosaicWorkspace.agentGrid(readNote(get));
return `${top}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
@@ -1677,6 +1699,13 @@
if (a === 'messages' && n === 1) return messagesView(get, q);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
// SP5: #/agents is every organization's agents; #/orgs/<id>/agents is that table for one, above its levels grid and held roles.
if (a === 'agents' && n === 1 && window.MosaicOrg) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
if (!org) return configRead.then(() => window.MosaicWorkspace.agentsPage(q, { scope: null, fireIn: decidesIn }));
return agentsView(get, q, org);
}
if (a === 'agents' && n === 1) return agentsView(get);
if (a === 'queue' && n === 1) return queueView(get, q);
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
@@ -1692,7 +1721,7 @@
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
// The cross-organization pages (U3) poll at the workspace read's pollMs.
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox');
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents');
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
@@ -2068,7 +2097,8 @@
// E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a
// fresh read of the page; editors in progress are kept by holdLive.
// Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll.
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
// SP5: the system Agents page shows every organization, so a change in any of them redraws it.
document.addEventListener('mosaic-org-changed', e => { if (!window.MosaicOrg) return; const path = routeOf().split('?')[0]; if ((path === '/agents' && window.MosaicOrg.system()) || (e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(path))) 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,
+3 -2
View File
@@ -22,8 +22,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// an organization page without the prefix gets the current organization's. #1873 SP2: an
// organization's own bare address (#/orgs/<id>, no trailing slash) is that organization's page;
// #/orgs/<id>/ stays the old dashboard address. SP3: `#/inbox` is a system route too (every
// organization's items); `#/orgs/<id>/inbox` is that list, filtered.
const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && bare === '/inbox'); };
// organization's items); `#/orgs/<id>/inbox` is that list, filtered. SP5: `#/agents` is every
// organization's agents; `#/orgs/<id>/agents` is that table for one organization.
const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents')); };
const normalize = (hash, seg = current) => {
const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1];
if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`;
+10
View File
@@ -692,6 +692,16 @@ html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center;
html[data-organizations] .ws-ibx-filters .ws-fchips>.ws-label,html[data-organizations] .ws-fgroup>.ws-label{min-width:36px}
html[data-organizations] .ws-ichips{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-top:3px}
html[data-organizations] .ws-ichips .via{display:inline}
/* #1873 SP5: the system Agents table. Open and Fire sit side by side at one size; a confirm wraps in its cell. */
html[data-organizations] .ws-agt .ws-arole{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-agt td.ws-act{white-space:nowrap;text-align:right;width:1%}
html[data-organizations] .ws-agt td.ws-act>*+*{margin-left:6px}
html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 10px;font:600 13px/1.4 var(--font);border-radius:var(--r)}
html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal}
html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere}
html[data-organizations] .ws-agt tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
html[data-organizations] .ws-agt tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
@media (forced-colors:active){html[data-organizations] .ws-agt tr.on td:first-child{border-left:3px solid Highlight}}
html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))}
html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none}
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
+130 -6
View File
@@ -363,6 +363,17 @@
return hit ? { key: hit[0], copy: hit[1] } : { key: null, copy: null };
};
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
// SP5: what an agent is, in words. A hired agent's definition is `org:hire-<decision>`. No read names
// the template it was hired from, so it reads as hired, and from which pack when a pack hired it;
// the definition id stays as secondary text.
const isHired = item => typeof item?.hireDecision === 'string' || /^org:hire-/.test(String(item?.definition ?? ''));
const packOf = item => (typeof item?.pack?.label === 'string' && item.pack.label ? item.pack.label : typeof item?.pack?.id === 'string' ? item.pack.id : '');
const ROLE_WORD = { 'chief-of-staff': 'Chief of staff', assistant: 'Assistant' };
const roleWords = item => {
const d = typeof item?.definition === 'string' ? item.definition : '';
if (isHired(item)) return packOf(item) ? `Hired from pack ${packOf(item)}` : 'Hired';
return ROLE_WORD[d] || d.replace(/^org:/, '') || 'not reported';
};
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
// Repaints that section alone when the choice changes, one renderer per organization: a side-by-side
@@ -392,7 +403,8 @@
if (!set && inst === null) return `<p class="ws-panel tight note" data-agents-why>${selOrg?.busHealthy === false ? `Agents not read: ${busWord(selOrg)}.` : 'Agents unavailable. The bus did not answer the instance read.'}</p>`;
if (!rows.length) return '<p class="ws-panel tight note">No agents configured</p>';
const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`);
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const hired = a => a.level === 'org' && isHired({ ...inst?.[a.name], definition: a.definition });
const sub = a => [hired(a) ? 'hired' : a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', hired(a) ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const status = a => {
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
if (a.level !== 'org') {
@@ -425,9 +437,10 @@
return `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col" class="hide-m">Memory</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${body}</tbody></table></div>${note}`;
}
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
// SP5: `opts.under` draws it below the scoped Agents table (#/orgs/<id>/agents), which owns the page heading.
async function agentGrid(note = '', selected = window.MosaicOrg?.id, opts = {}) {
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}</p></div>`;
const head = `<div class="ws-vh">${opts.under ? '<h2>Agents by level</h2>' : '<h1 tabindex="-1">Agents</h1>'}<div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
const admitted = valid.some(org => org.id === selected);
@@ -674,7 +687,7 @@
<section aria-label="Needs you and sessions">
${secH('Needs you', '', '<a href="#/inbox" data-focus-key="home-inbox">Inbox</a>')}
${needsList}${missLine(o, ['inbox'], 'needs')}
${secH('Active agents', 'sessions now', `<a href="${esc(selHref('/agents'))}" data-focus-key="home-agents">Agents</a>`)}
${secH('Active agents', 'sessions now', '<a href="#/agents" data-focus-key="home-agents">Agents</a>')}
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
</section>
</div>
@@ -879,6 +892,115 @@
</section>`;
}
// ---- agents across organizations (#1873 SP5, prototype shot 14) ----
// One table of every organization's agents: the organization's own instances and the root and
// project copies its bus booted with, from each organization's instances read; what each one works
// on from the workspace read's role sessions. Filters by organization and state live in the hash.
// `opts.scope` names one organization (#/orgs/<id>/agents): the same table, that organization only.
// `opts.fireIn(org)` says from that organization's own config whether this Console decides there;
// Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js).
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' };
async function agentsPage(q, opts = {}) {
// SP5 review F11: a scope that is not an organization id is refused, never widened to the system list.
if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '<section class="ws ws-agents-all" data-ws-agents data-ws-failed><div class="ws-vh"><h1 tabindex="-1">Agents</h1><p class="sub">That is not an organization address.</p></div></section>';
const scope = opts.scope ?? null;
const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id));
const want = String(q?.get?.('state') || '');
const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null;
let body;
try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); }
catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); }
const every = orgsOf(body);
const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every;
const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok');
// Each answering organization's instances, and (system page only) whether this Console decides there.
const reads = await Promise.all(askable.map(r => Promise.all([
data.instances(r.org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })),
!scope && typeof opts.fireIn === 'function' ? opts.fireIn(r.org).then(v => v === true, () => false) : false,
]).then(([v, fire]) => ({ id: r.org, ...v, fire }))));
const sessions = arr(body.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string');
const projects = arr(body.projects).filter(x => typeof x?.org === 'string');
const projectRef = (id, p) => { const hit = projects.find(x => x.org === id && (x.id === p || x.key === p)); return hit ? { id: hit.id, name: hit.name || hit.key || hit.id } : { id: p, name: p }; };
const all = [];
for (const r of reads) {
if (!r.inst) continue;
const mine = sessions.filter(x => x.org === r.id);
const agents = mine.map(x => ({ role: x.role, holder_run: x.run, state: x.state }));
const session = name => mine.find(x => x.role === name) || null;
for (const [name, item] of Object.entries(r.inst)) {
const d = activity(name, item, agents);
// Never Fire the chief, the assistant or an instance the bus would refuse to end.
const keep = item?.definition === 'chief-of-staff' || name === 'chief' || item?.definition === 'assistant' || item?.endProtected === true;
all.push({ org: r.id, key: name, name, level: 'org', role: roleWords(item), id: isHired(item) ? item.definition : '', dot: d, s: session(name), fire: r.fire && !keep });
}
for (const [key, c] of Object.entries(r.copies || {})) {
if (!c || typeof c.name !== 'string') continue;
const d = copyDot(key, c, agents);
all.push({ org: r.id, key, name: c.name, level: c.level === 'project' ? 'project' : 'root', role: roleWords(c), id: copyBlocked(c) ? copyWhy(c) : '', dot: d, s: session(key), project: c.level === 'project' && c.project ? projectRef(r.id, c.project) : null, fire: false });
}
}
for (const a of all) a.state = AGENT_STATE_KEY[a.dot[2]] || 'not-running';
const counts = new Map();
for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1);
const inOrg = all.filter(a => !org || org.includes(a.org)).sort((x, y) => x.org.localeCompare(y.org) || x.name.localeCompare(y.name) || x.key.localeCompare(y.key));
const filteredRows = inOrg.filter(a => !stateF || a.state === stateF);
// SP5 review F1/F2: Open addresses one agent (`#/orgs/<id>/agents?agent=<key>`). The scoped page puts
// that agent first and marks it, and draws no Open of its own, which would only link to itself.
const picked = scope ? String(q?.get?.('agent') || '') : '';
const hit = picked ? filteredRows.find(a => a.key === picked) || null : null;
const rows = hit ? [hit, ...filteredRows.filter(a => a !== hit)] : filteredRows;
const akey = a => `${a.org}:${a.key}`;
const orgChip = a => `<span class="ws-chip ws-o ws-ochip" style="${o.style(a.org)}" data-ws-orgchip><i aria-hidden="true"></i>${esc(o.name(a.org))}</span>`;
const projOf = a => a.project || a.s?.project || null;
const projChip = a => (projOf(a)?.name ? `<span class="ws-chip outline ws-pchip" data-ws-projchip>${esc(projOf(a).name)}</span>` : '');
const work = a => (a.s?.task ? `${esc(a.s.task.title || a.s.task.ref)}${a.s.task.title && a.s.task.ref ? `<span class="t2 mono">${esc(a.s.task.ref)}</span>` : ''}` : '<span class="muted">nothing reported</span>');
const LEVEL_WORD = { root: 'root agent copy', project: 'project agent' };
const row = a => `<tr class="ws-row ws-edge ws-o${a === hit ? ' on' : ''}" style="${o.style(a.org)}" data-ws-agent="${esc(akey(a))}"${a === hit ? ' aria-current="true"' : ''}>
<td class="t1">${esc(a.name)}${a.id ? `<span class="t2${a.level === 'org' ? ' mono' : ''}">${esc(a.id)}</span>` : ''}<span class="ws-ichips">${orgChip(a)}${projChip(a)}<span class="show-m ws-arole">${esc(a.role)}</span></span><span class="t2 show-m">${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'}</span></td>
<td class="hide-m" data-ws-role>${esc(a.role)}${a.level !== 'org' ? `<span class="t2">${esc(LEVEL_WORD[a.level])}</span>` : ''}</td>
<td>${dot(...a.dot, true)}</td>
<td class="hide-m" data-ws-work>${work(a)}</td>
${scope ? '' : `<td class="ws-act"><a class="ws-btn" href="${esc(`${orgHref(a.org)}/agents?agent=${encodeURIComponent(a.key)}`)}" data-ws-open data-focus-key="${esc(`open:${akey(a)}`)}">Open<span class="ws-sr"> ${esc(a.name)} in ${esc(o.name(a.org))}</span></a>${a.fire ? window.MosaicDecide?.fireControl(a.org, a.name, true, o.name(a.org)) ?? '' : ''}</td>`}
</tr>`;
// An organization that did not answer is named first, with the reason its read gave; one that
// answered but whose instances read failed is named too, never counted as having no agents.
const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
const failed = reads.filter(r => !r.inst && (!org || org.includes(r.id)));
const READ_ERR = { 403: 'refused', 0: 'signed out' };
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(why))}.</p>` : '')
+ (failed.length ? `<p class="note ws-miss" data-ws-miss="instances">Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(READ_ERR[r.err?.status] || r.err?.code || 'no answer')})`))}.</p>` : '')
+ missLine(o, ['agents', 'tasks'], 'agents', 'What agents work on was not read in');
const anyRead = reads.some(r => r.inst && (!org || org.includes(r.id)));
const filtered = (org && !scope) || stateF;
const table = rows.length
? `<div class="ws-tbl"><table class="ws-agt" data-ws-agent-list><caption class="ws-sr">Agents</caption><thead><tr><th scope="col">Agent</th><th scope="col" class="hide-m">Role</th><th scope="col">State</th><th scope="col" class="hide-m">Working on</th>${scope ? '' : '<th scope="col"><span class="ws-sr">Actions</span></th>'}</tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>`
: `<p class="ws-panel tight note" data-ws-agent-list>${!anyRead && o.rows.length ? 'Agents not read' : filtered ? 'Nothing matches these filters' : 'No agents'}</p>`;
const stateCounts = new Map();
for (const a of inOrg) stateCounts.set(a.state, (stateCounts.get(a.state) || 0) + 1);
const shown = AGENT_STATE.filter(([k]) => stateCounts.has(k) || k === stateF);
const stateChips = `<div class="ws-fchips" role="group" aria-label="State"><span class="ws-label">State</span><button type="button" class="f plain" data-ws-astate="" data-focus-key="astate:all" aria-pressed="${!stateF}">All <span class="n">${inOrg.length}</span></button>${shown.map(([k, w]) => `<button type="button" class="f plain" data-ws-astate="${k}" data-focus-key="${esc(`astate:${k}`)}" aria-pressed="${stateF === k}">${esc(w)} <span class="n">${stateCounts.get(k) || 0}</span></button>`).join('')}</div>`;
// The org chips count each answering organization's agents; an organization whose instances read
// failed shows "not read", as one that did not answer shows its state.
const notRead = new Set(reads.filter(r => !r.inst).map(r => r.id));
const chipOrgs = { ...every, rows: every.rows.map(r => (notRead.has(r.org) ? { ...r, errors: { ...(r.errors || {}), instances: 'not read' } } : r)) };
const orgsWith = new Set(rows.map(a => a.org)).size;
const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}.` : '';
const sub = scope
? `Agents in ${esc(o.name(scope))} only.${tally} <a href="#/agents" data-focus-key="agents-all">Every organization</a>`
: `Every agent on the system, by organization.${tally} Open one to see it in its organization.`;
return `<section class="ws ws-agents-all" data-ws-agents${scope ? ` data-ws-scope="${esc(scope)}"` : ''}>
<div class="ws-vh"><h1 tabindex="-1">Agents</h1><p class="sub">${sub}</p></div>
${unread}
<div class="ws-toolbar ws-ibx-filters">
${scope ? '' : `<div class="ws-fgroup"><span class="ws-label">Org</span>${orgChips(chipOrgs, counts, org, all.length, ['instances'])}</div>`}
${stateChips}
</div>
${table}
${scope ? '' : `<div data-agents-receipt="*" role="status">${window.MosaicDecide?.fireNote('*') ?? ''}</div>`}
</section>`;
}
// ---- controls: filters write the hash query and repaint in place ----
const pathOf = () => route().split('?')[0];
const queryOf = () => new URLSearchParams(route().split('?')[1] || '');
@@ -895,8 +1017,10 @@
if (pathOf() === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
}
document.addEventListener('click', e => {
const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox]');
const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox],[data-ws-agents]');
if (!page) return;
const st = e.target.closest('[data-ws-astate]');
if (st) { setQuery({ state: st.dataset.wsAstate || null }); return; }
const chip = e.target.closest('[data-ws-org]');
if (chip) {
const id = chip.dataset.wsOrg, now = listParam(queryOf(), 'org', x => ORG_ID.test(x)) || [];
@@ -931,6 +1055,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, inboxCount, setGrouping, get pollMs() { return pollMs; } });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
+3 -1
View File
@@ -71,7 +71,9 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
// SP3 (#1873): an answer from the system Inbox goes to its row's organization. decideBase is
// /api/orgs/<org> for such an answer and /api otherwise, so the route is still a decision's
// confirmation or resolve, only pinned to one organization.
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "'/api/decisions/hire/start'", 'url', '`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`', '`${decideBase(a)}/decisions/${enc(a.id)}/resolve`']);
// SP5 (#1873): Fire from the system Agents page goes to its row's organization the same way:
// decideBase({org}) is /api/orgs/<org>, and an agent card's Fire (no org) keeps /api.
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "`${decideBase({ org: named })}/decisions/hire/start`", 'url', '`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`', '`${decideBase(a)}/decisions/${enc(a.id)}/resolve`']);
assert.deepEqual([...read('bus.js').toString().matchAll(/const decideBase = [^;]*;/g)].map(m => m[0]), ["const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';"]);
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
// Row 176 (#1674): the tester name form adds a second /api/name caller.
@@ -130,8 +130,12 @@ test('an organization opens inside the same shell with a breadcrumb, and its pag
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.__marker'), 1);
// A page inside beta scopes its reads to beta.
// #1873 SP5: #/agents is the system Agents page, not beta's; it keeps the address and opens no organization.
await b.evaluate('location.hash = "#/agents"');
await wait('location.hash === "#/agents" && document.querySelector("[data-ws-agents]:not([data-ws-scope])")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
// A page inside beta scopes its reads to beta.
await b.evaluate('location.hash = "#/orgs/beta/agents"');
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('window.__marker'), 1);
@@ -0,0 +1,258 @@
// SP5 (#1873, single pane slice 5): one Agents page for the whole system at #/agents. Every
// organization that answers adds its agents to one table: name, role, state, what it works on, its
// organization chip and a project chip when the work has a project. Filters by organization and state
// live in the hash query. Fire on a row goes to the row's own organization, never to the one opened
// last, and is offered only where that organization's own config says this Console decides. A stopped
// organization is named at the top with its reason. #/orgs/<id>/agents shows the same table, scoped.
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and
// the real per-organization routes serve the page.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
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();
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
function fixture({ empty = false } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const DATA = {
acme: {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
// A built-in store task assigned to coder, filed under the Website project.
tasks: [{ task_ref: 'store:1', source: 'store', at: at(20), fields: { title: 'Hero copy', state: 'in-progress', group: 'p-site', assignees: ['coder'] } }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
},
beta: {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
instances: { pm: { definition: 'pm', running: ['run-9'] }, 'research-2': { definition: `org:hire-${HIRE}`, hireDecision: HIRE, running: [], pack: { id: 'coding', label: 'Coding' } } },
},
};
if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; }
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') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme } },
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' } }),
options: () => opts(org.id),
});
return {
list: async () => LIST,
members: async () => 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), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: {
messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances }),
},
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
};
}
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every
// organization's config says this Console decides there, except those named in `noDecide`; decision
// POSTs are recorded and answered from a queue, so no real write happens.
const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`;
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = [];
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const u = String(url);
if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) {
const c = await (await real(url, init)).json();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) });
}
if (!u.includes('/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, body: init.body });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
return answer(a.status ?? 200, a.body);
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, stub = false, noDecide = [] } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const rows = 'JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent))';
const are = list => `${rows} === ${JSON.stringify(JSON.stringify(list))}`;
const ALL = ['acme:chief', 'acme:coder', 'beta:pm', 'beta:research-2'];
test('#/agents lists both organizations\' agents in one table, each row with its organization chip', async t => {
const { b, wait, text } = await setup(t);
await wait(are(ALL));
// A system route: the address keeps no organization and the sidebar Agents link is this page.
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/agents');
assert.equal(await text('#bus-view h1'), 'Agents');
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-orgchip]'), 'Acme Studio');
assert.equal(await text('[data-ws-agent="beta:pm"] [data-ws-orgchip]'), 'Beta Works');
// State from the bus, and what the agent works on: its task, with the task's project chip.
assert.match(await text('[data-ws-agent="acme:coder"]'), /working/);
assert.match(await text('[data-ws-agent="beta:pm"]'), /waiting on you/);
assert.match(await text('[data-ws-agent="acme:coder"] [data-ws-work]'), /Hero copy/);
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-projchip]'), 'Website');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-ws-projchip]\')'), null);
// A hired agent reads by its name and role; the definition id stays as secondary text.
const hired = await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] .t1\')?.firstChild?.textContent');
assert.equal(hired, 'research-2');
assert.match(await text('[data-ws-agent="beta:research-2"] [data-ws-role]'), /Hired.*Coding/);
assert.match(await text('[data-ws-agent="beta:research-2"] .t2'), new RegExp(`org:hire-${HIRE}`));
assert.match(await text('[data-ws-agent="acme:chief"] [data-ws-role]'), /Chief of staff/);
// Each row opens the agent in its own organization.
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm');
});
test('the organization and state filters live in the hash and survive a reload', async t => {
const { b, base, wait } = await setup(t);
await wait(are(ALL));
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=beta]").click()');
await wait(`location.hash.startsWith("#/agents?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${are(['beta:pm', 'beta:research-2'])}`);
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
await wait(`${are(['beta:pm', 'beta:research-2'])} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`);
// State: one chip per state the rows carry.
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=\'\']").click()');
await wait(are(ALL));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-astate]")].map(x => x.dataset.wsAstate)'), ['', 'working', 'waiting', 'not-running']);
await b.evaluate('document.querySelector("[data-ws-astate=not-running]").click()');
await wait(`new URLSearchParams(location.hash.split("?")[1]).get("state") === "not-running" && ${are(['acme:chief', 'beta:research-2'])}`);
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
await wait(`${are(['acme:chief', 'beta:research-2'])} && document.querySelector("[data-ws-astate=not-running]")?.getAttribute("aria-pressed") === "true"`);
// A value the page does not know is dropped, never read as a filter.
await b.evaluate('location.hash = "#/agents?state=bogus&org=Not%20An%20Org"');
await wait(are(ALL));
await b.evaluate('location.hash = "#/agents?org=beta&state=working"');
await wait('document.querySelector("[data-ws-agent-list]")?.textContent === "Nothing matches these filters"');
});
test('firing beta\'s agent from #/agents while acme was opened last reaches beta', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/agents"');
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
// Never on the chief.
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="acme:chief"] [data-agent-fire]\')'), null);
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\').click()');
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\')');
await b.evaluate('window.__answers.push({ body: { id: "dec-fire-1", op: "resolved", choice: "yes" } })');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\').click()');
await wait('/Fired pm/.test(document.querySelector("[data-ws-agents] [data-agents-receipt]")?.textContent ?? "")');
// The receipt names the organization by its label, not its id.
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /^Fired pm in Beta Works\./);
const posts = await b.evaluate('window.__posts');
assert.deepEqual(posts.map(p => p.url), ['/api/orgs/beta/decisions/hire/start']);
assert.deepEqual(JSON.parse(posts[0].body), { business: 'beta', hire: { op: 'fire', level: 'org', name: 'pm', reason: 'Fired from the Agents page.' } });
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /dec-fire-1/);
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('Fire follows each organization\'s own config, not the organization opened last', async t => {
// Acme is opened last and this Console decides there; beta's own config says it does not.
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['beta'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/agents"');
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="acme:coder"] [data-agent-fire]')`);
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\')'), null);
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-agent-fire]\')'), null);
});
test('a stopped organization is named at the top with its reason', async t => {
const { b, wait, text } = await setup(t);
await wait(are(ALL));
const note = await text('[data-ws-unread]');
assert.match(note, /Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector("[data-ws-agent-list]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
});
test('with no agents anywhere the table says so and still names the stopped organization', async t => {
const { wait, text } = await setup(t, { empty: true });
await wait('document.querySelector("[data-ws-agent-list]")');
assert.equal(await text('[data-ws-agent-list]'), 'No agents');
assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s);
});
test('#/orgs/<id>/agents shows that organization\'s rows only, with a link to every organization', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/agents' });
await wait(are(['beta:pm', 'beta:research-2']));
assert.match(await text('[data-ws-agents] .sub'), /Beta Works/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] a[href=\'#/agents\']") !== null'), true);
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
// The organization's own levels grid and held roles still follow, under the one page heading.
await wait('document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1);
});
test('Open addresses the agent: the scoped page puts it first, marks it and draws no Open of its own', async t => {
const { b, wait } = await setup(t);
await wait(are(ALL));
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').getAttribute("href")'), '#/orgs/beta/agents?agent=research-2');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').click()');
await wait(are(['beta:research-2', 'beta:pm']));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents?agent=research-2');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"]\').getAttribute("aria-current")'), 'true');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"]\').hasAttribute("aria-current")'), false);
// No self-link on the scoped page.
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] [data-ws-open]").length'), 0);
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] thead th").length'), 4);
// An agent the organization does not have marks nothing.
await b.evaluate('location.hash = "#/orgs/beta/agents?agent=nobody"');
await wait(`${are(['beta:pm', 'beta:research-2'])} && !document.querySelector('[data-ws-agents] [aria-current]')`);
});
test('Cancel puts focus back on the row\'s Fire, which is labelled with the organization\'s name', async t => {
const { b, wait } = await setup(t, { stub: true });
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
const fire = '\'[data-ws-agent="beta:pm"] [data-agent-fire]\'';
assert.equal(await b.evaluate(`document.querySelector(${fire}).getAttribute('aria-label')`), 'Fire pm in Beta Works');
await b.evaluate(`document.querySelector(${fire}).click()`);
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\')');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-confirm]\').getAttribute("aria-label")'), 'Fire pm in Beta Works?');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\').click()');
await wait(`document.activeElement === document.querySelector(${fire})`);
assert.deepEqual(await b.evaluate('window.__posts'), []);
});
test('a picked agent stays in the address through the state filter, and a scope that is not an organization id is refused', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/beta/agents?agent=research-2' });
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agents] [data-ws-astate="waiting"]')`);
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate="waiting"]\').click()');
await wait(are(['beta:pm']));
const q = new URLSearchParams((await b.evaluate('location.hash')).split('?')[1]);
assert.equal(q.get('agent'), 'research-2');
assert.equal(q.get('state'), 'waiting');
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] [aria-current]")'), null);
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate=""]\').click()');
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agent="beta:research-2"][aria-current="true"]')`);
const refused = await b.evaluate('window.MosaicWorkspace.agentsPage(new URLSearchParams(), { scope: "../beta" })');
assert.match(refused, /data-ws-failed/);
assert.doesNotMatch(refused, /data-ws-agent=/);
});
@@ -484,7 +484,8 @@ test('agents grid shows each agent\'s level and where it is defined, per project
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /social-scheduler \(Spring launch only\), copywriter \(excluded in this project\) and every Forge or Home agent/);
// Home, where the reviewer is turned off: the table says so.
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/home/agents`);
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
// #1873 SP5: the scoped page puts the levels grid under its own "Agents by level" h2; this is the section's title.
await waitFor(b)('document.querySelector("[data-org-agents] .ws-split-agents h2")?.innerText === "Agents in Home"');
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
});