feat(webui): show thread claims separately in Agents

This commit is contained in:
jason.woltje committed 2026-10-22 06:40:05 -05:00
1 parent 2c350a4669
commit e7d21b91b8
6 files changed
+238 -3

No files matched your search

+9
View File
@@ -382,6 +382,15 @@ 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".
The same view has a separate **Thread claims** section (row 278, #1783).
It reads `thread.claims` and shows thread id, holder run, harness and claim time,
in the bus's order. Thread names, messages, instance and address are not shown.
An empty read has an empty-state line. A refused or failed read clears only
this section's kept claims and shows its reason; the held roles and their
count stay intact. Sign-out clears the claims with the other bus reads.
The read needs no `--send`. The bus permits human and reader sessions;
the tester listener refuses it with HTTP 403 `tester-refused` before any bus call.
## Board: held roles and signing out (row 95, #1589)
The Board (`#/`) opens with **Held roles**: each role a run holds on the
+3 -1
View File
@@ -83,7 +83,9 @@ try {
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
// (mos-0840): so is `routes`, and its write `routes.set`.
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
// Row 278: this owner/reader read belongs to the Console, not the shared
// role views or the tester adapter. The bus decides caller eligibility.
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') };
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
projectFiles = args => call('project.files', args);
projectFile = args => call('project.file', args);
+22 -1
View File
@@ -269,6 +269,7 @@
// reply to its own message, so a reply to a Console send stays unread in `mosaic inbox` until
// the human marks it read with `mosaic inbox --read`.
let isTester = false;
let configRead = Promise.resolve();
let canSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
@@ -469,12 +470,32 @@
(el || $('main')).focus({ preventScroll: true });
}
}
// Row 278: a claims refusal belongs to this section. Only sign-out reaches
// the global handler; a 403 here must not forget the successful role read.
async function threadClaimsSection(get) {
await configRead;
if (isTester) return '';
const title = '<h2 id="b-thread-claims">Thread claims</h2>';
try {
const claims = await get('thread.claims');
if (out()) { last.delete('thread.claims'); throw signedOut(); }
if (!claims.every(c => c && ['thread', 'holder_run', 'harness', 'at'].every(k => typeof c[k] === 'string'))) throw new ReadError('invalid-response', '');
const rows = claims.map(c => `<tr><th scope="row">${txt(c.thread)}</th><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}</td><td>${when(c.at)}</td></tr>`).join('');
return `<section aria-labelledby="b-thread-claims">${title}${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Thread claims, scroll for all columns"><table class="s1-table s1-thread-claims"><thead><tr>${['Thread', 'Holder run', 'Harness', 'Claimed at'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No thread claim is held right now.', 'A thread appears while a run holds its claim on the bus.')}</section>`;
} catch (err) {
if (err.code === 'signed-out') throw err;
last.delete('thread.claims');
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) {
const claims = await get('agents');
const threadSection = await threadClaimsSection(get);
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. 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>
${threadSection}
<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>`;
}
@@ -1293,7 +1314,7 @@
});
// Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
// Message buttons are drawn from the roles already read.
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && c?.send === true;
+4 -1
View File
@@ -61,7 +61,7 @@ async function body(req, max = 4096) {
// (#1649) adds `names`: each instance's current seat name, for the Assistant section. Row 165
// (#1662) adds `instances`: the business file's role instances and the definitions add accepts.
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'thread.claims', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
const DECISION_LISTS = ['roles', 'recent'];
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
@@ -69,6 +69,7 @@ const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
async function busRead(res, bus, verb, search) {
let arg;
if (verb === 'thread.claims' && search) return json(res, 400, { error: 'invalid-request', message: 'thread.claims takes no arguments' });
if (verb === 'usage') {
if (search) return json(res, 400, { error: 'invalid-request', message: 'usage takes no arguments' });
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
@@ -735,6 +736,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
// Owner/reader metadata is never a tester read, even with a supplied bus.
if (tester && verb === 'thread.claims') return json(res, 403, { error: 'tester-refused' });
return busRead(res, bus, verb, search);
}
if (path === '/api/task-fields') {
@@ -0,0 +1,132 @@
// Row 278: real Console rendering and HTTP with mutable bus-answer stand-ins.
// Broker claim/release semantics are covered by row 277; this tests the reader.
import test from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { fixture, close } from './fixture.mjs';
import { browser } from './browser.mjs';
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
import { testerBrowser } from './tester-browser-fixture.mjs';
import { tmp } from '../../cli/tests/helpers.mjs';
const AT = '2026-01-01T00:00:00Z';
const THREAD = '11111111-1111-4111-8111-111111111111';
const claim = { thread: THREAD, holder_run: 'thread-run', harness: 'pi', at: AT };
async function setup(t) {
const f = await fixture(); t.after(() => f.close());
const state = { rows: [], failure: null, offline: false, calls: [], hold: false, release: null };
t.after(() => { state.hold = false; state.release?.(); });
const role = { role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT, state: 'idle', inputNeeded: [] };
const read = (verb, answer) => async () => {
state.calls.push(verb);
if (state.offline) throw new BusReadError('outcome-unknown');
if (verb === 'thread.claims' && state.failure) throw new BusReadError(state.failure);
if (verb === 'thread.claims' && state.hold) await new Promise(resolve => { state.release = resolve; });
return typeof answer === 'function' ? answer() : answer;
};
const bus = { agents: read('agents', [role]), inbox: read('inbox', []), messages: read('messages', { messages: [], unread: 0 }), 'thread.claims': read('thread.claims', () => state.rows) };
const web = await startServer({ port: 0, board: f.boardURL, bus }); t.after(() => close(web));
const b = await browser(); t.after(() => b.close());
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}throw Error(${JSON.stringify(expression)})})()`);
await b.navigate(`http://127.0.0.1:${web.address().port}/#/agents`);
await wait(`!!document.querySelector('#b-thread-claims')`);
const roleHtml = () => b.evaluate(`document.querySelector('[aria-labelledby="b-claims"]').outerHTML`);
const refresh = () => b.evaluate(`document.querySelector('#refresh').click(),true`);
return { b, state, wait, roleHtml, refresh };
}
test('row278 browser lists held claims and release preserves role rows and totals', async t => {
const { b, state, wait, roleHtml, refresh } = await setup(t);
const before = await roleHtml();
assert.match(await b.evaluate(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent`), /No thread claim is held/);
state.rows = [claim]; await refresh();
await wait(`document.querySelector('.s1-thread-claims tbody')?.textContent.includes('${THREAD}')`);
assert.deepEqual(await b.evaluate(`Array.from(document.querySelectorAll('.s1-thread-claims thead th'),e=>e.textContent)`), ['Thread', 'Holder run', 'Harness', 'Claimed at']);
assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1');
assert.equal(await roleHtml(), before);
assert.equal(await b.evaluate(`document.querySelector('.s1-thread-claims time').dateTime`), AT);
state.rows = []; await refresh();
await wait(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent.includes('No thread claim is held')`);
assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0);
assert.equal(await roleHtml(), before);
});
test('row278 browser claims 403 stays local and role section remains byte-equal', async t => {
const { b, state, wait, roleHtml, refresh } = await setup(t);
const before = await roleHtml(); state.rows = [claim]; await refresh();
await wait(`!!document.querySelector('.s1-thread-claims tbody tr')`);
for (const code of ['human-required', 'not-configured', 'outcome-unknown']) {
state.failure = code; await refresh();
await wait(`document.querySelector('[aria-labelledby="b-thread-claims"] code')?.textContent===${JSON.stringify(code)}`);
assert.equal(await roleHtml(), before, code);
assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1');
assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0, 'failed read drops claims only');
}
state.offline = true; await refresh();
await wait(`!!document.querySelector('.s1-stale') && document.querySelector('[aria-labelledby="b-thread-claims"] code')?.textContent==='not-read'`);
assert.equal(await roleHtml(), before, 'other reads may use the kept role section');
assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0, 'a later stale repaint must not recover refused claims');
});
test('row278 browser renders only four escaped claim fields and never instance or other extras', async t => {
const { b, state, wait, refresh } = await setup(t);
const hostile = '<img src=x onerror="window.claimInjected=1">';
state.rows = [{ ...claim, thread: hostile, holder_run: '<script>window.claimInjected=2</script>', harness: '<b>pi</b>',
instance: 'NEVER_INSTANCE', name: 'NEVER_THREAD_NAME', body: 'NEVER_MESSAGE_BODY', address: 'NEVER_ADDRESS' }];
await refresh(); await wait(`document.querySelector('.s1-thread-claims tbody')?.textContent.includes('onerror')`);
const text = await b.evaluate(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent`);
assert.ok(text.includes(hostile)); assert.ok(text.includes('<script>window.claimInjected=2</script>')); assert.ok(text.includes('<b>pi</b>'));
assert.doesNotMatch(text, /NEVER_INSTANCE|NEVER_THREAD_NAME|NEVER_MESSAGE_BODY|NEVER_ADDRESS/);
assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr > *').length`), 4);
assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims script,.s1-thread-claims img,.s1-thread-claims b').length`), 0);
assert.equal(await b.evaluate(`!!window.claimInjected`), false);
});
test('row278 browser sign-out removes claims and prevents reuse of cached answers', async t => {
const { b, state, wait, refresh } = await setup(t);
state.rows = [claim]; await refresh(); await wait(`!!document.querySelector('.s1-thread-claims tbody tr')`);
await b.evaluate(`window.claimFetch=fetch;window.fetch=(url,options)=>url==='/api/bus/thread.claims'?Promise.resolve(new Response(JSON.stringify({error:'sign-in required'}),{status:401})):window.claimFetch(url,options);true`);
await refresh();
await wait(`document.body.classList.contains('signed-out') && !document.querySelector('#b-thread-claims')`);
const calls = state.calls.length; await refresh(); assert.equal(state.calls.length, calls);
// Simulate returning to the route while the bus is unavailable. A cache that
// survived sign-out would repopulate the held thread from reader(false).
state.offline = true;
await b.evaluate(`window.fetch=window.claimFetch;document.body.classList.remove('signed-out');location.hash='#/agents?after-signout=1';true`);
await wait(`document.querySelector('#bus-view h1')?.textContent==='The read failed'`);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), new RegExp(THREAD));
});
test('row278 tester browser keeps role rows without requesting the owner claims section', async t => {
const dataRoot = tmp(t, 'claims-view-'), calls = [], reads = [];
const web = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: {
agents: async () => [{ role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT }],
messages: async () => ({ messages: [], unread: 0 }),
'thread.claims': async () => { calls.push('thread.claims'); return [claim]; },
} });
t.after(() => close(web));
const b = await testerBrowser(web); t.after(() => b.close());
b.on('Fetch.requestPaused', ({ request }) => { if (new URL(request.url).pathname === '/api/bus/thread.claims') reads.push(request.url); });
await b.navigate(TESTER_ORIGIN + '/#/agents');
await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(document.querySelector('#tester-banner')&&document.querySelector('.s1-agents tbody tr'))return true;await new Promise(r=>setTimeout(r,30));}throw Error('tester Agents view did not render')})()`);
assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1');
assert.equal(await b.evaluate(`document.querySelector('#b-thread-claims')===null`), true);
assert.deepEqual(calls, []);
assert.deepEqual(reads, []);
});
test('row278 browser discards a late claims answer after sign-out', async t => {
const { b, state, wait, refresh } = await setup(t);
await b.evaluate(`window.claimFetch=fetch;window.fetch=async(url,options)=>{const r=await window.claimFetch(url,options);if(url==='/api/bus/thread.claims'){const json=r.json.bind(r);r.json=async()=>{const body=await json();window.lateClaimsFinished=true;return body;};}return r;};true`);
state.rows = [claim]; state.hold = true; await refresh();
for (let n = 0; !state.release && n < 100; n++) await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(typeof state.release, 'function');
await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')),true`);
state.release(); state.hold = false;
await wait(`window.lateClaimsFinished && document.body.classList.contains('signed-out') && !document.querySelector('#b-thread-claims')`);
state.offline = true;
await b.evaluate(`window.fetch=window.claimFetch;document.body.classList.remove('signed-out');location.hash='#/agents?late-answer=1';true`);
await wait(`document.querySelector('#bus-view h1')?.textContent==='The read failed'`);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), new RegExp(THREAD));
});
@@ -0,0 +1,68 @@
// Row 278 (#1783): Console-owned read adapter and the listener boundary.
// All listeners and config are fixtures; no live bus or host configuration.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { startServer } from '../src/serve.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
const rows = [{ thread: '11111111-1111-4111-8111-111111111111', instance: 'assistant', holder_run: 'thread-run', harness: 'pi', at: '2026-01-01T00:00:00Z' }];
test('row278 HTTP read returns held claims without send and refuses arguments before the bus', async t => {
const calls = [];
const bus = { 'thread.claims': async arg => { calls.push(arg); return rows; } };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const url = `http://127.0.0.1:${web.address().port}/api/bus/thread.claims`;
const result = await fetch(url);
assert.equal(result.status, 200); assert.equal(result.headers.get('cache-control'), 'no-store');
const body = await result.json(); assert.deepEqual(body.rows, rows); assert.ok(body.at);
assert.deepEqual(calls, [undefined]);
for (const query of ['?thread=one', '?subject=one', '?limit=1', '?unknown=']) {
const r = await fetch(url + query); assert.equal(r.status, 400); assert.equal((await r.json()).error, 'invalid-request');
}
assert.equal((await fetch(url, { method: 'POST' })).status, 405);
assert.equal(calls.length, 1);
});
test('row278 HTTP read preserves bus refusal and rejects a malformed result', async t => {
let value = new BusReadError('human-required');
const web = await startServer({ port: 0, bus: { 'thread.claims': async () => { if (value instanceof Error) throw value; return value; } } });
t.after(() => close(web));
const url = `http://127.0.0.1:${web.address().port}/api/bus/thread.claims`;
let r = await fetch(url); assert.equal(r.status, 403); assert.equal((await r.json()).error, 'human-required');
value = {};
r = await fetch(url); assert.equal(r.status, 502); assert.equal((await r.json()).error, 'invalid-response');
value = [];
r = await fetch(url); assert.equal(r.status, 200); assert.deepEqual((await r.json()).rows, []);
});
test('row278 tester socket refuses thread claims explicitly with zero bus calls', async t => {
const dataRoot = tmp(t, 'claims-tester-'); let calls = 0;
const web = await startTesterSocket({ dataRoot, bus: { 'thread.claims': async () => { calls++; return rows; } } });
t.after(() => close(web));
const r = await testerRequest({ dataRoot, path: '/api/bus/thread.claims' });
assert.equal(r.status, 403); assert.equal(r.body.error, 'tester-refused'); assert.equal(calls, 0);
});
test('row278 Console adapter calls argument-free thread.claims through the normal transport', t => {
const root = tmp(t, 'claims-cli-'), f = fixture(root), log = join(root, 'calls.jsonl'), hook = join(root, 'hooks.mjs');
writeFileSync(log, '');
const serveSource = `export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){await o.bus['thread.claims']();return {address:()=>({port:0}),close:()=>{}};}`;
const busSource = `import {appendFileSync} from 'node:fs';export const busCaller=o=>async(verb,...args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,tester:!!o.cli})+'\\n');return [];};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
return next(url,context);
}});`);
const r = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--business', 'acme', '--port', '0'],
{ env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 });
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.deepEqual(readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse), [{ verb: 'thread.claims', args: [], tester: false }]);
});