diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 69c5b4212..851de9047 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -68,6 +68,12 @@ // An agent's activity is the state the bus derives for the run holding its role. A live process // alone says only running, never working or idle. const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] }; + // SP11 (#1873): the agent counts share two words. Running is an agent row with a live process, + // whatever it is doing; working is a running one whose role session is working. + const LIVE_STATES = new Set(['working', 'waiting', 'idle', 'running']); + const runningOf = rows => rows.filter(a => LIVE_STATES.has(a.state)).length; + const workingOf = rows => rows.filter(a => a.state === 'working').length; + const agentNote = (run, waiting) => `${run} running${waiting ? `, ${waiting} waiting on you` : ''}`; const activity = (name, item, agents) => { const on = running(item) > 0; const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null; @@ -297,18 +303,22 @@ const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer'); const kpi = orgsOf(ws), oq = `org=${enc(id)}`; const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) }); - const working = orgAgents?.filter(a => a.state === 'working').length ?? 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; + const working = orgAgents ? workingOf(orgAgents) : 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; const orgTiles = `
- ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: waiting ? `${waiting} waiting on you` : '' })} + ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })} ${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })} ${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })} ${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
`; const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model; - const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null; + // SP11: the sessions are the running rows the tile counts: the organization's instances and the + // root and project copies its bus booted with. A copy shows its own name. + const copyLive = Object.entries(copiesOf(value(2))).filter(([, c]) => c && typeof c.name === 'string' && !copyBlocked(c) && running(c)).map(([key, c]) => [key, c, c.name]); + const live = instances ? [...Object.entries(instances).filter(([, item]) => running(item)).map(([key, item]) => [key, item, key]), ...copyLive] : null; + const liveWorking = live ? live.filter(([key, item]) => activity(key, item, agents)[2] === 'working').length : 0; // A session's project and task is the thread its live run holds a claim on (thread.claims), not // any older conversation of the same agent. A failed read says unavailable, never "none". - const sessionRows = (live || []).map(([agent, item]) => { + const sessionRows = (live || []).map(([agent, item, shown]) => { const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null; const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null; const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents); @@ -317,7 +327,7 @@ : !first ? 'No conversation held right now' : thread ? `${esc(projectName(thread.project))}${esc(thread.name || thread.id)}${more}` : threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable'; - return `${esc(agent)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; + return `${esc(shown)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); const sessions = live === null ? (stopped ? '

Sessions not read: the bus is stopped.

' : '

Sessions unavailable. The bus did not answer the instance read.

') : live.length ? `
${sessionRows}
AgentProject and taskModelStatus
` : '

No active agent sessions

'; const open = queue ? queue.filter(r => r?.state !== 'done').length : null; @@ -338,7 +348,7 @@ ${docrow}
- ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `This organization's agents`)} + ${secH('Active agent sessions', live ? (live.length ? `${live.length} running, ${liveWorking} working` : 'none running') : '', `This organization's agents`)} ${sessions} ${secH('Queue', queueNote, 'Open queue')} ${queueWhy} @@ -669,7 +679,7 @@ const needsN = fig('needs', ps.needs); const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0); const tiles = !o.rows.length ? '

No organizations yet, so there is nothing to count.

' : `
- ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} + ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} ${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })} ${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })} ${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })} @@ -679,15 +689,17 @@ // SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an // organization that did not answer shows its state and reason instead of figures. - const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions); + const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs); const none = '–'; const orgTable = o.rows.map(r => { const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id); const [cls, glyph, word] = rowState(r, fromList); const lv = levels?.orgs?.find(v => v?.id === id); - const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; - const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null; - const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` (${working} working)`}`; + // SP11: an organization whose agents were read counts the tile's rows, running and working; one + // that was not (a stopped bus) counts the agents in its file and says nothing of what runs. + const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null; + const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; + const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` (${runningOf(mineRows)} running, ${workingOf(mineRows)} working)` : ''}`; const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none; const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none; // #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once. @@ -950,7 +962,7 @@ // `opts.scope` names one organization (#/orgs//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 = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['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' }; // Each named organization's instances read, and whether this Console decides there when `fireIn` is given. const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([ @@ -1044,7 +1056,8 @@ 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')}`}.` : ''; + // SP11: running and working in the Dashboard tiles' words, over the rows shown. + const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}: ${runningOf(rows)} running, ${workingOf(rows)} working.` : ''; const sub = scope ? `Agents in ${esc(o.name(scope))} only.${tally} Every organization` : `Every agent on the system, by organization.${tally} Open one to see it in its organization.`; diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index 16adcf8ad..63d06cac0 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -200,6 +200,30 @@ test('An organization page leads with its own tiles, each linking to the system await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])'); }); +// SP11 (#1873): "working" and "running" each name what they count. Running is an agent with a live +// process, whatever it is doing; working is a running agent whose role session is working. The tile, +// the Dashboard organization rows and the sessions heading count the same agent rows. +const delta = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-delta:not(.ws-miss)`)})?.textContent.trim() ?? null`; +const rowAgents = org => `document.querySelector("[data-ws-orgrow=${org}] td:nth-child(2)")?.innerText.replace(/\\s+/g, " ").trim() ?? null`; +test('Agent counts say running and working, and count the same agents on the tiles, the organization rows and the sessions heading', async t => { + const { b, wait, text } = await setup(t); + await wait(homeReady); + // acme's coder runs and works; beta's pm runs and waits on you; chief and writer run nothing. + assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4'); + assert.equal(await b.evaluate(delta('[data-ws-home]', 'agents')), '2 running, 1 waiting on you'); + assert.equal(await b.evaluate(rowAgents('acme')), '2 agents (1 running, 1 working)'); + assert.equal(await b.evaluate(rowAgents('beta')), '2 agents (1 running, 0 working)'); + await b.evaluate('location.hash = "#/orgs/beta"'); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")'); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2'); + assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running, 1 waiting on you'); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 0 working/); + await b.evaluate('location.hash = "#/orgs/acme"'); + await wait(`location.hash.startsWith("#/orgs/acme") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`); + assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running'); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/); +}); + test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => { const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' }); await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")'); diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs index 0b153b08d..914c31f3c 100644 --- a/packages/webui/tests/system-agents-browser.test.mjs +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -17,7 +17,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b'; -function fixture({ empty = false } = {}) { +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 }, @@ -38,6 +38,8 @@ function fixture({ empty = false } = {}) { }, }; 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') }, @@ -90,8 +92,8 @@ const STUB = `(() => { }; })();`; -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: () => {} }); +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); @@ -132,6 +134,19 @@ test('#/agents lists both organizations\' agents in one table, each row with its 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));