diff --git a/packages/webui/README.md b/packages/webui/README.md index 74d438c71..59cb4ee3a 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -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 diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 8f4c45de8..300e239a0 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -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); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 9acbe1da6..1cb248984 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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 = '
| ${h} | `).join('')}
|---|
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.
${claims.length ? `| ${h} | `).join('')}
|---|
Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}
Board seats stay on the control board. This view is about roles.
`; } @@ -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; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index c67dc17e1..288871d0c 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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') { diff --git a/packages/webui/tests/thread-claims-browser.test.mjs b/packages/webui/tests/thread-claims-browser.test.mjs new file mode 100644 index 000000000..a76697299 --- /dev/null +++ b/packages/webui/tests/thread-claims-browser.test.mjs @@ -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 = '