279 lines
20 KiB
JavaScript
279 lines
20 KiB
JavaScript
// 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, unheld = 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 = []; }
|
|
// `unheld`: a live designer run that holds no role session, so the bus reports no state for it.
|
|
if (unheld) DATA.acme.instances.designer = { definition: 'designer', running: ['run-5'] };
|
|
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, unheld = false, stub = false, noDecide = [] } = {}) {
|
|
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty, unheld }), 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 trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
|
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');
|
|
// SP7: the page name shows once, as the heading; a trail with nothing above it is not drawn.
|
|
assert.equal(await b.evaluate(trail), null);
|
|
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');
|
|
});
|
|
|
|
// SP11 (#1873): the page says how many of its rows run and how many work, in the words the Dashboard
|
|
// tiles use. A live agent with no reported state is "Running, state unknown", not a bare "Running"
|
|
// that reads as every running agent.
|
|
test('#/agents counts its running and working agents, and names a running agent with no reported state', async t => {
|
|
const { b, wait, text } = await setup(t, { unheld: true });
|
|
await wait(are(['acme:chief', 'acme:coder', 'acme:designer', 'beta:pm', 'beta:research-2']));
|
|
assert.match(await text('[data-ws-agents] .sub'), /^Every agent on the system, by organization\. 5 agents in 2 organizations: 3 running, 1 working\./);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-ws-astate=running]")?.innerText.replace(/\\s+/g, " ").trim()'), 'Running, state unknown 1');
|
|
await b.evaluate('document.querySelector("[data-ws-astate=working]").click()');
|
|
await wait(are(['acme:coder']));
|
|
assert.match(await text('[data-ws-agents] .sub'), /1 agent in 1 organization: 1 running, 1 working\./);
|
|
});
|
|
|
|
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');
|
|
// SP7: a drill-in keeps its trail back to the dashboard and the organization.
|
|
await wait(`${trail} === 'Dashboard / Beta Works / 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=/);
|
|
});
|