--- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -86 +86,3 @@ - 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') }; --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -64 +64 @@ -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']; @@ -71,0 +72 @@ + if (verb === 'thread.claims' && search) return json(res, 400, { error: 'invalid-request', message: 'thread.claims takes no arguments' }); @@ -737,0 +739,2 @@ + // 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' }); --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -271,0 +272 @@ + let configRead = Promise.resolve(); @@ -471,0 +473,18 @@ + // 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}
`; + } + } @@ -473,0 +493 @@ + const threadSection = await threadClaimsSection(get); @@ -477,0 +498 @@ + ${threadSection} @@ -1296 +1317 @@ - 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 => { --- /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 class BusReadError extends Error{};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 }]); +}); --- /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)); +}); --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -384,0 +385,9 @@ +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. + --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -97 +97 @@ - const rows = { inbox: [], tasks: [], agents: [], trail: [] }; + const rows = { inbox: [], tasks: [], agents: [], trail: [], 'thread.claims': [] }; @@ -101 +101 @@ - return { rows, hold, fail: code => { fail = code; }, bus: { inbox: verb('inbox'), tasks: verb('tasks'), agents: verb('agents'), trail: verb('trail') } }; + return { rows, hold, fail: code => { fail = code; }, bus: { inbox: verb('inbox'), tasks: verb('tasks'), agents: verb('agents'), trail: verb('trail'), 'thread.claims': verb('thread.claims') } };