392 lines
27 KiB
Diff
392 lines
27 KiB
Diff
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
||
index 5b57b7fc..30ed0ab5 100644
|
||
--- a/packages/bus/README.md
|
||
+++ b/packages/bus/README.md
|
||
@@ -125,9 +125,37 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
||
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
|
||
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
|
||
-| `inbox`, `tasks`, `agents` | No args; plain JSON projections scoped to capability's business. |
|
||
+| `inbox`, `tasks` | No args; plain JSON projections scoped to capability's business. |
|
||
+| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working` and `inputNeeded` (Agent state, below); a role session gets the claim rows only. |
|
||
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
|
||
|
||
+### Agent state (row 59, #1548)
|
||
+
|
||
+For a human or reader session, `agents` derives each held role's state from
|
||
+records the bus already keeps. Nothing new is written and no schema changed.
|
||
+
|
||
+- `working` is `{messages, since}` or null: messages to the role that the
|
||
+ current holder received and hasn't marked read, and when the oldest was
|
||
+ received. The runner receives a batch, takes one turn per message, replies
|
||
+ and marks it read, so working means a turn is under way or queued.
|
||
+- `inputNeeded` lists the role's open decisions routed to the human, gated
|
||
+ first and then oldest, as the Inbox orders them. Each item is
|
||
+ `{id, question, class, blocking, task_ref, at}`. Seen is still open.
|
||
+- `state` is `working` when `working` is set, else `waiting` when
|
||
+ `inputNeeded` is nonempty, else `idle`. Working wins over waiting, as on
|
||
+ the control board.
|
||
+
|
||
+Why the bus and not a seat-to-role map: the business file's `holder` is a
|
||
+display name, and the claim's holder is a run, not a seat, so no committed
|
||
+data joins a seat to a role. The bus already has the claim, the deliveries
|
||
+and the decisions.
|
||
+
|
||
+Limits: a message routed but not yet received shows idle. A reply that fails
|
||
+for a reason other than a gone session leaves its message unread, so the role
|
||
+stays working; `since` shows how long. No error or offline state is derived.
|
||
+A new holder doesn't inherit the previous holder's unread deliveries;
|
||
+decisions stay with the role. Unheld and released roles aren't listed.
|
||
+
|
||
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
|
||
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
|
||
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
|
||
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
||
index 84db8799..d471d600 100644
|
||
--- a/packages/bus/src/broker.mjs
|
||
+++ b/packages/bus/src/broker.mjs
|
||
@@ -825,11 +825,14 @@ export class Broker {
|
||
s.human || s.reader ? 'human' : s.role,
|
||
)
|
||
.map((d) => this.#decisionView(d));
|
||
- if (verb === 'agents')
|
||
- return this.#store.all(
|
||
+ if (verb === 'agents') {
|
||
+ const claims = this.#store.all(
|
||
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
|
||
s.business,
|
||
);
|
||
+ // A role session gets the claims only: another role's decisions aren't its to read.
|
||
+ return s.human || s.reader ? claims.map((c) => this.#agentState(c)) : claims;
|
||
+ }
|
||
if (verb === 'tasks')
|
||
return this.#store
|
||
.all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', s.business)
|
||
@@ -861,6 +864,28 @@ export class Broker {
|
||
fail('unknown-verb');
|
||
}
|
||
}
|
||
+ // Row 59 (#1548): a held role's state for the Agents view, derived from what the bus
|
||
+ // already records. Working: messages the current holder received and hasn't marked read;
|
||
+ // the runner marks each one read after its turn. Input needed: the role's open decisions
|
||
+ // routed to the human, in Inbox order. Working wins over waiting, as on the control board.
|
||
+ #agentState(c) {
|
||
+ const busy = this.#store.get(
|
||
+ "SELECT count(*) AS messages, min(d.at) AS since FROM messages m JOIN deliveries d ON d.message=m.id AND d.op='delivered' WHERE m.business=? AND m.to_role=? AND d.holder_run=? AND NOT EXISTS(SELECT 1 FROM deliveries r WHERE r.message=m.id AND r.op='read')",
|
||
+ c.business,
|
||
+ c.role,
|
||
+ c.holder_run,
|
||
+ );
|
||
+ const inputNeeded = this.#store
|
||
+ .all(
|
||
+ `SELECT d.* FROM decisions d WHERE business=? AND raised_by_role=? AND route_to='human' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY CASE class WHEN 'gated' THEN 0 ELSE 1 END,seq`,
|
||
+ c.business,
|
||
+ c.role,
|
||
+ )
|
||
+ .map((d) => ({ id: d.id, question: d.question, class: d.class, blocking: !!d.blocking, task_ref: d.task_ref, at: d.at }));
|
||
+ const working = busy.messages ? { messages: busy.messages, since: busy.since } : null;
|
||
+ const state = working ? 'working' : inputNeeded.length ? 'waiting' : 'idle';
|
||
+ return { ...c, state, working, inputNeeded };
|
||
+ }
|
||
#claimEnd(s, h, op, decision) {
|
||
this.#store.run(
|
||
'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)',
|
||
diff --git a/packages/bus/tests/agents.test.mjs b/packages/bus/tests/agents.test.mjs
|
||
new file mode 100644
|
||
index 00000000..23b5126b
|
||
--- /dev/null
|
||
+++ b/packages/bus/tests/agents.test.mjs
|
||
@@ -0,0 +1,159 @@
|
||
+// Row 59 (#1548): `agents` gives human and reader sessions each held role's state,
|
||
+// derived from records the bus already keeps. A role session gets the claims only.
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { mkdtempSync, rmSync } from 'node:fs';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { join } from 'node:path';
|
||
+import { Store } from '../src/store.mjs';
|
||
+import { Broker } from '../src/broker.mjs';
|
||
+
|
||
+const options = [
|
||
+ { key: 'yes', text: 'Allow' },
|
||
+ { key: 'no', text: 'Decline' },
|
||
+];
|
||
+const roles = {
|
||
+ pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||
+ cto: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||
+ coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||
+};
|
||
+const businesses = {
|
||
+ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
|
||
+ other: { id: 'other', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
|
||
+};
|
||
+const CLAIM = ['seq', 'at', 'business', 'role', 'op', 'holder_run', 'harness', 'address', 'by', 'reason', 'decision'];
|
||
+function setup(t) {
|
||
+ const root = mkdtempSync(join(tmpdir(), 'bus-agents-'));
|
||
+ const store = new Store(root);
|
||
+ const b = new Broker({ store, businesses });
|
||
+ t.after(() => {
|
||
+ store.close();
|
||
+ rmSync(root, { recursive: true, force: true });
|
||
+ });
|
||
+ const agent = (role, business = 'demo', run = `${business}-${role}-run`) => {
|
||
+ const cap = b.bindLaunch({ business, role, run, harness: 'pi', address: run });
|
||
+ b.request(cap, { verb: 'role.claim' });
|
||
+ return cap;
|
||
+ };
|
||
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||
+ const reader = b.bindReader({ business: 'demo' });
|
||
+ return { b, store, agent, human, reader };
|
||
+}
|
||
+const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
|
||
+const raise = (b, cap, action, extra = {}) =>
|
||
+ call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
|
||
+const byRole = (rows) => Object.fromEntries(rows.map((r) => [r.role, r]));
|
||
+const states = (b, cap) => Object.fromEntries(call(b, cap, 'agents').map((r) => [r.role, r.state]));
|
||
+const delivered = (store, id) => store.get("SELECT at FROM deliveries WHERE message=? AND op='delivered'", id).at;
|
||
+
|
||
+test('agents: a held role with nothing on the bus is idle; a role session gets the claim rows unchanged', (t) => {
|
||
+ const { b, agent, human, reader } = setup(t);
|
||
+ const pm = agent('pm');
|
||
+ agent('coder');
|
||
+ const rows = call(b, human, 'agents');
|
||
+ assert.deepEqual(rows.map((r) => [r.role, r.state, r.working, r.inputNeeded]), [
|
||
+ ['coder', 'idle', null, []],
|
||
+ ['pm', 'idle', null, []],
|
||
+ ]);
|
||
+ for (const r of rows) assert.deepEqual(Object.keys(r), [...CLAIM, 'state', 'working', 'inputNeeded']);
|
||
+ assert.deepEqual(call(b, reader, 'agents'), rows);
|
||
+ const plain = call(b, pm, 'agents');
|
||
+ for (const r of plain) assert.deepEqual(Object.keys(r), CLAIM);
|
||
+ assert.deepEqual(plain.map((r) => ({ ...r })), rows.map((r) => Object.fromEntries(CLAIM.map((k) => [k, r[k]]))));
|
||
+});
|
||
+
|
||
+test('agents: working counts what the holder received and has not read; routed alone is not working', (t) => {
|
||
+ const { b, store, agent, human } = setup(t);
|
||
+ const pm = agent('pm');
|
||
+ const one = call(b, human, 'message.send', { to: 'pm', body: 'first' });
|
||
+ assert.equal(states(b, human).pm, 'idle', 'routed, not received');
|
||
+ assert.deepEqual(call(b, pm, 'message.receive').map((m) => m.id), [one.id]);
|
||
+ const two = call(b, human, 'message.send', { to: 'pm', body: 'second' });
|
||
+ let row = byRole(call(b, human, 'agents')).pm;
|
||
+ assert.equal(row.state, 'working');
|
||
+ assert.deepEqual(row.working, { messages: 1, since: delivered(store, one.id) });
|
||
+ call(b, pm, 'message.receive');
|
||
+ row = byRole(call(b, human, 'agents')).pm;
|
||
+ assert.deepEqual(row.working, { messages: 2, since: delivered(store, one.id) });
|
||
+ call(b, pm, 'message.read', { id: one.id });
|
||
+ row = byRole(call(b, human, 'agents')).pm;
|
||
+ assert.deepEqual(row.working, { messages: 1, since: delivered(store, two.id) });
|
||
+ call(b, pm, 'message.read', { id: two.id });
|
||
+ row = byRole(call(b, human, 'agents')).pm;
|
||
+ assert.deepEqual([row.state, row.working], ['idle', null]);
|
||
+});
|
||
+
|
||
+test("agents: input needed is the role's open decisions routed to the human, gated first, then oldest", (t) => {
|
||
+ const { b, agent, human } = setup(t);
|
||
+ const cto = agent('cto');
|
||
+ const coder = agent('coder');
|
||
+ agent('pm');
|
||
+ // The arbiter raising a cross-role decision in its own domain routes it to the human.
|
||
+ const own = raise(b, cto, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' });
|
||
+ const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/2' });
|
||
+ const first = raise(b, cto, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true });
|
||
+ const second = raise(b, cto, 'deploy', { target: 'release-2' });
|
||
+ const replaced = raise(b, cto, 'deploy', { target: 'release-3' });
|
||
+ raise(b, cto, 'deploy', { target: 'release-3b', supersedes: replaced.id, question: 'Ship 3b?' });
|
||
+ const resolved = raise(b, cto, 'deploy', { target: 'release-4' });
|
||
+ call(b, human, 'decision.resolve', { id: resolved.id, choice: 'no' });
|
||
+ call(b, human, 'decision.seen', { id: second.id });
|
||
+ assert.deepEqual([own.route_to, own.class, toCto.route_to, first.route_to, first.class], ['human', 'cross-role', 'cto', 'human', 'gated']);
|
||
+ const rows = byRole(call(b, human, 'agents'));
|
||
+ assert.equal(rows.cto.state, 'waiting');
|
||
+ assert.equal(rows.cto.working, null);
|
||
+ const latest = call(b, human, 'inbox').find((d) => d.question === 'Ship 3b?');
|
||
+ assert.deepEqual(rows.cto.inputNeeded, [
|
||
+ { id: first.id, question: 'deploy?', class: 'gated', blocking: true, task_ref: 'vikunja:3/3', at: first.at },
|
||
+ { id: second.id, question: 'deploy?', class: 'gated', blocking: false, task_ref: null, at: second.at },
|
||
+ { id: latest.id, question: 'Ship 3b?', class: 'gated', blocking: false, task_ref: null, at: latest.at },
|
||
+ { id: own.id, question: 'task.scope.change?', class: 'cross-role', blocking: false, task_ref: null, at: own.at },
|
||
+ ]);
|
||
+ // The inbox shows the same open decisions; a decision routed to a role is no one's Input needed.
|
||
+ assert.deepEqual(call(b, human, 'inbox').map((d) => d.id), rows.cto.inputNeeded.map((d) => d.id));
|
||
+ assert.deepEqual([rows.coder.state, rows.coder.inputNeeded, rows.pm.state], ['idle', [], 'idle']);
|
||
+ for (const d of rows.cto.inputNeeded) call(b, human, 'decision.resolve', { id: d.id, choice: 'no' });
|
||
+ assert.deepEqual([byRole(call(b, human, 'agents')).cto.state, byRole(call(b, human, 'agents')).cto.inputNeeded], ['idle', []]);
|
||
+});
|
||
+
|
||
+test('agents: working wins over waiting, and Input needed still shows', (t) => {
|
||
+ const { b, agent, human } = setup(t);
|
||
+ const cto = agent('cto');
|
||
+ const asked = raise(b, cto, 'deploy', { target: 'release-1' });
|
||
+ call(b, human, 'message.send', { to: 'cto', body: 'status?' });
|
||
+ call(b, cto, 'message.receive');
|
||
+ const row = byRole(call(b, human, 'agents')).cto;
|
||
+ assert.equal(row.state, 'working');
|
||
+ assert.equal(row.working.messages, 1);
|
||
+ assert.deepEqual(row.inputNeeded.map((d) => d.id), [asked.id]);
|
||
+});
|
||
+
|
||
+test("agents: a new holder doesn't inherit what the last one left unread; decisions stay with the role", (t) => {
|
||
+ const { b, agent, human } = setup(t);
|
||
+ const first = agent('cto', 'demo', 'cto-one');
|
||
+ const asked = raise(b, first, 'deploy', { target: 'release-1' });
|
||
+ call(b, human, 'message.send', { to: 'cto', body: 'left unread' });
|
||
+ call(b, first, 'message.receive');
|
||
+ assert.equal(states(b, human).cto, 'working');
|
||
+ call(b, first, 'role.release');
|
||
+ assert.deepEqual(call(b, human, 'agents'), [], 'a released role is not listed');
|
||
+ agent('cto', 'demo', 'cto-two');
|
||
+ const row = byRole(call(b, human, 'agents')).cto;
|
||
+ assert.deepEqual([row.holder_run, row.state, row.working], ['cto-two', 'waiting', null]);
|
||
+ assert.deepEqual(row.inputNeeded.map((d) => d.id), [asked.id]);
|
||
+});
|
||
+
|
||
+test('agents: state reads only its own business, even for a run name both use', (t) => {
|
||
+ const { b, agent, human } = setup(t);
|
||
+ // A run name is unique per business only, so the holder alone doesn't separate them.
|
||
+ agent('cto', 'demo', 'cto-run');
|
||
+ const away = agent('cto', 'other', 'cto-run');
|
||
+ raise(b, away, 'deploy', { target: 'release-1' });
|
||
+ const otherHuman = b.bindHuman({ business: 'other', human: 'jason', via: 'cli', outsideAgent: true });
|
||
+ call(b, otherHuman, 'message.send', { to: 'cto', body: 'elsewhere' });
|
||
+ call(b, away, 'message.receive');
|
||
+ const here = byRole(call(b, human, 'agents')).cto;
|
||
+ assert.deepEqual([here.state, here.working, here.inputNeeded], ['idle', null, []]);
|
||
+ const there = byRole(call(b, otherHuman, 'agents')).cto;
|
||
+ assert.deepEqual([there.state, there.working.messages, there.inputNeeded.length], ['working', 1, 1]);
|
||
+});
|
||
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
||
index e43118d5..092b6b0e 100644
|
||
--- a/packages/webui/README.md
|
||
+++ b/packages/webui/README.md
|
||
@@ -101,6 +101,17 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
|
||
config, or no bus host and no `--business`) or "The read failed".
|
||
|
||
+## Agent state (row 59, #1548)
|
||
+
|
||
+`#/agents` adds State and Attention columns. State is the board's mark
|
||
+(● working, ◐ waiting, – idle) from the bus `agents` verb, with the number
|
||
+of messages and since when for a working role. Attention shows
|
||
+"Input needed:" with the first line of the role's first open decision,
|
||
+linked to `#/inbox/<id>`, a blocking chip, and "+N more" to the Inbox when
|
||
+there are others. The page says what each state means. It writes nothing;
|
||
+the server passes the rows through unchanged. How the bus derives the state
|
||
+and its limits: `packages/bus/README.md`, "Agent state".
|
||
+
|
||
## Shell (row 53, #1542)
|
||
|
||
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
|
||
diff --git a/packages/webui/src/public/bus.css b/packages/webui/src/public/bus.css
|
||
index 39cf2991..54d2c736 100644
|
||
--- a/packages/webui/src/public/bus.css
|
||
+++ b/packages/webui/src/public/bus.css
|
||
@@ -25,7 +25,7 @@ body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px
|
||
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
|
||
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
|
||
/* Tables */
|
||
-table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
|
||
+table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:1000px}
|
||
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
|
||
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
|
||
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
|
||
index affb9b7b..8422a87a 100644
|
||
--- a/packages/webui/src/public/bus.js
|
||
+++ b/packages/webui/src/public/bus.js
|
||
@@ -325,12 +325,23 @@
|
||
}
|
||
|
||
// Agents
|
||
+ // Row 59 (#1548): the state mark is the control board's (shell.css), glyph and word. The
|
||
+ // broker derives the state from its own records: working while the holder has messages it
|
||
+ // received and hasn't answered, waiting while a decision the role raised waits for the
|
||
+ // human, otherwise idle.
|
||
+ const stateMark = state => `<span class="badge st st-${esc(state || 'unknown')} badge-${{ working: 'ok', waiting: 'attn' }[state] || 'muted'}">${esc(state || 'unknown')}</span>`;
|
||
+ function attention(c) {
|
||
+ const asks = Array.isArray(c.inputNeeded) ? c.inputNeeded : [];
|
||
+ if (!asks.length) return none;
|
||
+ const d = asks[0];
|
||
+ return `Input needed: <a href="#/inbox/${enc(d.id)}">${txt(first(d.question))}</a> <span class="source">${txt(short(d.id))}</span>${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` <a href="#/inbox">+${esc(asks.length - 1)} more</a>` : ''}`;
|
||
+ }
|
||
async function agentsView(get) {
|
||
const claims = await get('agents');
|
||
- const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||
+ 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(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||
return `${head('Agents', readNote())}
|
||
- <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.</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', '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>
|
||
+ <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>
|
||
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
|
||
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
|
||
}
|
||
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
|
||
index aebd3c30..200f9428 100644
|
||
--- a/packages/webui/tests/bus-browser.test.mjs
|
||
+++ b/packages/webui/tests/bus-browser.test.mjs
|
||
@@ -36,7 +36,10 @@ function seed(root) {
|
||
const store = new Store(root), b = new Broker({ store, businesses });
|
||
const launch = (role, harness, address) => { const cap = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness, address }); b.request(cap, { verb: 'role.claim' }); return cap; };
|
||
const coder = launch('coder', 'pi', 'coder-run'), pm = launch('pm', 'pi', 'pm-run');
|
||
- launch('cto', 'claude-code', 'cto-thread');
|
||
+ const cto = launch('cto', 'claude-code', 'cto-thread');
|
||
+ // Row 59 (#1548): CTO has a message it received and hasn't answered, so it is working.
|
||
+ b.request(pm, { verb: 'message.send', args: { to: 'cto', body: 'Review the inbox design' } });
|
||
+ b.request(cto, { verb: 'message.receive' });
|
||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||
// The human's request, the task PM created for it, a later poll that moved it, and a refused write.
|
||
const asked = b.request(human, { verb: 'message.send', args: { to: 'pm', body: `Build the inbox ${HOSTILE}` } });
|
||
@@ -62,7 +65,9 @@ function seed(root) {
|
||
const calls = [];
|
||
let fail = null;
|
||
const bus = views({ call: async (verb, args = {}) => { calls.push(verb); if (fail) throw new BusReadError(fail); return structuredClone(b.request(reader, { verb, args })); } });
|
||
- return { store, bus, calls, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
|
||
+ // Row 59 (#1548): a second open decision for Coder, raised and withdrawn on the Agents page.
|
||
+ const second = { raise: () => raise({ ...push, question: 'Tag the release?', target: 'tag', blocking: false }).id, withdraw: id => b.request(coder, { verb: 'decision.withdraw', args: { id } }) };
|
||
+ return { store, bus, calls, second, fail: code => { fail = code; }, ids: { old: old.id, open: open.id, routed: routed.id, resolved: resolved.id } };
|
||
}
|
||
|
||
test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes', { timeout: 180000 }, async () => {
|
||
@@ -212,6 +217,26 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
|
||
await go('#/agents', 'Agents');
|
||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody th")].map(e=>e.textContent)').then(r => r.sort()), ['coder', 'cto', 'pm']);
|
||
assert.match(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r=>r.querySelector("th").textContent==="cto").textContent'), /cto-runclaude-codecto-thread/);
|
||
+ // Row 59 (#1548): the state mark and Input needed per role. Coder's open push decision waits on the human.
|
||
+ const agentRow = role => b.evaluate(`(() => { const r = [...document.querySelectorAll("#bus-view table.s1-agents tbody tr")].find(r => r.querySelector("th").textContent === ${JSON.stringify(role)}); return [...r.querySelectorAll("td")].map(td => td.textContent); })()`);
|
||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents thead th")].map(e=>e.textContent)'), ['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since']);
|
||
+ const [coderRow, ctoRow, pmRow] = [await agentRow('coder'), await agentRow('cto'), await agentRow('pm')];
|
||
+ assert.equal(coderRow[0], 'waiting'); assert.match(coderRow[1], /^Input needed: Push the release\? .*blocking$/);
|
||
+ assert.ok(!coderRow[1].includes('Second line'), 'only the first line of the question');
|
||
+ assert.match(ctoRow[0], /^working1 message since /); assert.equal(ctoRow[1], 'none');
|
||
+ assert.deepEqual([pmRow[0], pmRow[1]], ['idle', 'none']);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#bus-view table.s1-agents td a[href^=\\"#/inbox/\\"]")?.getAttribute("href")'), `#/inbox/${s.ids.open}`);
|
||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents .st")].map(e=>[e.className.split(" ").find(c=>c.startsWith("st-")), getComputedStyle(e,"::before").content])').then(r => r.sort()), [['st-idle', '"–" / ""'], ['st-waiting', '"◐" / ""'], ['st-working', '"●" / ""']]);
|
||
+ // Gated first, then oldest: the push decision stays first and the new one is counted.
|
||
+ const extra = s.second.raise();
|
||
+ await b.evaluate('document.querySelector("#refresh").click()');
|
||
+ await wait('document.querySelector("#bus-view table.s1-agents")?.textContent.includes("+1 more")');
|
||
+ assert.match((await agentRow('coder'))[1], /^Input needed: Push the release\? .*blocking \+1 more$/);
|
||
+ assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-agents td a")].find(a=>a.textContent==="+1 more").getAttribute("href")'), '#/inbox');
|
||
+ s.second.withdraw(extra);
|
||
+ await b.evaluate('document.querySelector("#refresh").click()');
|
||
+ await wait('!document.querySelector("#bus-view table.s1-agents")?.textContent.includes("more")');
|
||
+ await inertCheck('agents');
|
||
for (const label of ['Role instances, launch limits and model families', 'Launch state', 'Live and ended sessions', 'Credential notices'])
|
||
assert.ok((await text('#bus-view')).includes(`${label}: not in the Q1 module`), label);
|
||
await noOverflow('agents');
|
||
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
|
||
index 2d9ca6ba..5b078168 100644
|
||
--- a/packages/webui/tests/bus.test.mjs
|
||
+++ b/packages/webui/tests/bus.test.mjs
|
||
@@ -70,6 +70,8 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t
|
||
|
||
const agents = await read('/api/bus/agents');
|
||
assert.deepEqual(agents.body.rows.map(r => [r.role, r.op]), [['coder', 'claim']]);
|
||
+ // Row 59 (#1548): the reader view carries the state the broker derives.
|
||
+ assert.deepEqual([agents.body.rows[0].state, agents.body.rows[0].working, agents.body.rows[0].inputNeeded.map(d => [d.id, d.blocking, d.class])], ['waiting', null, [[decision.id, true, 'gated']]]);
|
||
|
||
const id = inbox.body.rows[0].id;
|
||
const trail = await read('/api/bus/trail?subject=' + encodeURIComponent(id));
|