feat(webui): one Agents page for the whole system, with organization and state filters, Fire sent to the row's organization (#1873, lane sp5)
#/agents lists every organization's agents in one table: name, role, state, what each works on, organization and project chips. Org and state filters live in the hash query. A stopped organization is named at the top. A row opens the agent in its organization; Fire is drawn only where that organization's own config lets this Console decide, and posts to that organization's hire route. #/orgs/<id>/agents leads with the same table scoped to one organization, above the levels grid and held roles. A hired agent reads as Hired (or Hired from pack X) with its org:hire id as secondary text. The sidebar and dashboard Agents links open the system page.
This commit is contained in:
1 parent
3885f4628b
commit
5373b110ac
8 files changed
+401
-35
No files matched your search
@@ -488,46 +488,59 @@
|
||||
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){
|
||||
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}});
|
||||
const {res,json}=await decidePost(`${decideBase({ org: named })}/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;
|
||||
if(res?.type==='opaqueredirect'){signOut();return;}
|
||||
const where=named?` in ${org}`:'';
|
||||
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);
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
const fireButton=(name,org=null)=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}"${fireOrgAttr(org)} data-focus-key="${fireFocus('fire',name,org)}" aria-label="Fire ${esc(name)}${org?` in ${esc(org)}`:''}">Fire</button>`;
|
||||
const fireConfirm=(name,org=null)=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}?" data-agent-fire-confirm="${esc(name)}"${fireOrgAttr(org)}>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){
|
||||
const named=typeof allowed==='boolean';
|
||||
if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return '';
|
||||
return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null):fireButton(name,named?org: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();
|
||||
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named);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);announce('Cancelled. Nothing was fired.');}
|
||||
else void fireAgent(t.dataset.agentFireYes,named);
|
||||
});
|
||||
window.MosaicDecide=Object.freeze({fireControl,fireNote});
|
||||
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
|
||||
@@ -876,12 +889,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 +914,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 +1693,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 +1715,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 +2091,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,
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -692,6 +692,13 @@ 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-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}
|
||||
|
||||
@@ -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,108 @@
|
||||
</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 = {}) {
|
||||
const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.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 rows = inOrg.filter(a => !stateF || a.state === stateF);
|
||||
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" style="${o.style(a.org)}" data-ws-agent="${esc(akey(a))}">
|
||||
<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>
|
||||
<td class="ws-act"><a class="ws-btn" href="${esc(`${orgHref(a.org)}/agents`)}" 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) ?? '' : ''}</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><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 +1010,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 +1048,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();
|
||||
})();
|
||||
@@ -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,210 @@
|
||||
// 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');
|
||||
});
|
||||
|
||||
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 ?? "")');
|
||||
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);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user