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 = '

Thread claims

'; + 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 => `${txt(c.thread)}${txt(c.holder_run)}${txt(c.harness)}${when(c.at)}`).join(''); + return `
${title}${claims.length ? `
${['Thread', 'Holder run', 'Harness', 'Claimed at'].map(h => ``).join('')}${rows}
${h}
` : empty('No thread claim is held right now.', 'A thread appears while a run holds its claim on the bus.')}
`; + } catch (err) { + if (err.code === 'signed-out') throw err; + last.delete('thread.claims'); + return `
${title}
`; + } + } async function agentsView(get) { const claims = await get('agents'); + const threadSection = await threadClaimsSection(get); const rows = claims.map(c => `${txt(c.role)}${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}${attention(c)}${txt(c.holder_run)}${txt(c.harness)}${c.address ? `${txt(c.address)}` : ''}${when(c.at)}`).join(''); return `${head('Agents', readNote())}

Held roles ${claims.length}

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 ? `
${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => ``).join('')}${rows}
${h}
` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}
+ ${threadSection}

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 = ''; + state.rows = [{ ...claim, thread: hostile, holder_run: '', harness: 'pi', + 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('')); assert.ok(text.includes('pi')); + 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)); +}); diff --git a/packages/webui/tests/thread-claims.test.mjs b/packages/webui/tests/thread-claims.test.mjs new file mode 100644 index 000000000..0b332c6cf --- /dev/null +++ b/packages/webui/tests/thread-claims.test.mjs @@ -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 }]); +});