feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512)
One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:
- Row 18, Discord connector rows on the board (#1509): R3 approved by
Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
Dewey, candidate manifest 47769fad. All seven source files match it.
Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).
packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.
Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.
Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.
Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -8,8 +8,12 @@
|
||||
let board = 'the configured board', palette = 'harbor', mode = 'light';
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||||
// Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
|
||||
const setBy = r => r.taskSetBy ? `<span class="source">set by ${esc(r.taskSetBy)}</span>` : '';
|
||||
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
|
||||
const canReply = r => r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
|
||||
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
|
||||
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const row = id => data?.sessions.find(r => key(r) === id);
|
||||
const announce = text => { $('announce').textContent = text; };
|
||||
function theme() {
|
||||
@@ -33,11 +37,11 @@
|
||||
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
|
||||
function cards(rows) {
|
||||
if (!rows.length) return '<p class="muted">Nothing here.</p>';
|
||||
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}</p><p class="preview">${esc(r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
|
||||
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
|
||||
}
|
||||
function table(rows) {
|
||||
if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
function registered(r) {
|
||||
const reg = r.registered;
|
||||
@@ -51,9 +55,11 @@
|
||||
if (!selected) return;
|
||||
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
|
||||
$('inspector-title').textContent = r.agent;
|
||||
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], ['Last activity', r.lastActivity || 'unknown'] ];
|
||||
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', r.lastActivity || 'unknown'] ];
|
||||
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
|
||||
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
|
||||
const receipt = receipts.get(selected);
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>Last assistant text</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>Last error</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : '<p class="muted">reply needs a registered seat</p>'}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
}
|
||||
// Restore by data attribute equality, never interpolate API ids into selectors.
|
||||
function focusSnapshot() {
|
||||
|
||||
@@ -29,12 +29,16 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||
const agent = 'agent"[\\<script>', project = '__proto__';
|
||||
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
|
||||
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', taskSetBy: '<img src=x onerror=alert(2)>', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
|
||||
index = { sessions: [rec], counts: { waiting: 1 } };
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelectorAll("table.sessions [data-open]").length===1');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), agent);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#inspection img").length'), 0);
|
||||
// #1511: a hostile setter value is text, in the table and the inspector.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("img").length'), 0);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*<img src=x onerror=alert\(2\)> \(as claimed/);
|
||||
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by <img src=x onerror=alert\(2\)>/);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
|
||||
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'original draft' });
|
||||
await b.evaluate('document.querySelector(".reply-form").requestSubmit();document.querySelector(".reply-form").requestSubmit()');
|
||||
@@ -50,6 +54,29 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /reply needs a registered seat/);
|
||||
// #1511: attribution never enables a reply: a setter with no registration at all still gets no form.
|
||||
const savedRegistration = index.sessions[0].registered;
|
||||
index.sessions[0].registered = null; index.sessions[0].taskSetBy = 'someone';
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*someone/);
|
||||
// R1-U1: null means not applicable (task not selected from a registration); it is never shown as "unknown",
|
||||
// and a previous setter never lingers after the transition.
|
||||
index.sessions[0].registered = savedRegistration; index.sessions[0].taskSetBy = null;
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("someone")'), false);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
|
||||
assert.equal(await b.evaluate('document.querySelector("table.sessions td.task").textContent.includes("set by")'), false);
|
||||
// A selected legacy registration (record predates taskSetBy) reads "unknown", distinct from not applicable.
|
||||
index.sessions[0].taskSource = 'registration'; index.sessions[0].taskSetBy = 'unknown';
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown \(as claimed by the caller, not verified\)/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("not applicable")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by unknown/);
|
||||
index.sessions[0].taskSource = 'first-user-message'; index.sessions[0].taskSetBy = null;
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("set by unknown")'), false);
|
||||
index.sessions[0].registered.alive = true;
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!!document.querySelector("#reply")');
|
||||
assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
|
||||
|
||||
@@ -34,6 +34,19 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'inspector-title');
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Registered fixture task/);
|
||||
// #1511: attribution shows for the registered task only, as a claim, and only where the task is the registered one.
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*fixture-setter \(as claimed by the caller, not verified\)/);
|
||||
assert.match(await b.evaluate('document.querySelector("table.sessions tr[data-row=\\"proj/agent1\\"] td.task").textContent'), /set by fixture-setter/);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("table.sessions td.task")].filter(td=>td.textContent.includes("set by")).length'), 1, 'transcript-derived rows carry no attribution');
|
||||
assert.match(await b.evaluate('document.querySelector("#waiting .wait-item p:nth-of-type(2)").textContent'), /set by fixture-setter|Build the fixture task/);
|
||||
// R1-U1: a transcript-derived task is "not applicable", never "unknown", and never borrows the registered setter.
|
||||
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open!=="proj/agent1").focus()'); await b.key('Enter');
|
||||
await wait('!document.querySelector("#inspection").textContent.includes("Registered fixture task")');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("fixture-setter")'), false);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
|
||||
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").focus()'); await b.key('Enter');
|
||||
await wait('document.querySelector("#inspection").textContent.includes("Registered fixture task")');
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
await b.evaluate('document.querySelector("#reply").focus()');
|
||||
await b.call('Input.insertText', { text: 'keep this draft' });
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
|
||||
let board, web, b;
|
||||
try {
|
||||
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
|
||||
writeFileSync(join(dir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
writeFileSync(join(dir, 'STOP'), 'PRIVATE STOP');
|
||||
const sessions = join(root, 'sessions', 'discord-pilot'); mkdirSync(sessions, { recursive: true });
|
||||
writeFileSync(join(sessions, 's.jsonl'), JSON.stringify({ type: 'message', timestamp: '2026-09-14T00:00:00Z', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: '<script>window.injected=true</script> completed' }] } }) + '\n');
|
||||
// #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
|
||||
const seatsDir = join(root, 'seats');
|
||||
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' });
|
||||
board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } });
|
||||
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const payload = await (await fetch(base + '/api/board')).json();
|
||||
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle');
|
||||
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
|
||||
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
|
||||
assert.equal(JSON.stringify(payload).includes('forged'), false);
|
||||
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
|
||||
assert.equal(reply.status, 409);
|
||||
b = await browser(); await b.viewport(320, 1000);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
|
||||
await b.navigate(`http://127.0.0.1:${board.address().port}`);
|
||||
await wait('document.querySelector(".session-row")');
|
||||
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
|
||||
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
await b.navigate(base);
|
||||
await wait('document.querySelector("table.sessions [data-open]")');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /braked \(STOP\); owner live/);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Board replies disabled for Discord connectors/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
// R1-U1: the connector's fixed task is "not applicable", never "unknown", and never the forged native setter.
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
|
||||
assert.equal(await b.evaluate('document.body.textContent.includes("forged")'), false);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
|
||||
rmSync(join(dir, 'STOP')); rmSync(join(dir, 'run.lock', 'owner.json'));
|
||||
await b.evaluate('document.querySelector("#hide-offline").click();document.querySelector("#refresh").click()');
|
||||
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
|
||||
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
|
||||
export async function fixture() {
|
||||
@@ -14,7 +14,7 @@ export async function fixture() {
|
||||
const captures = [];
|
||||
for (const [agent, project, state] of [['agent1', 'proj', 'waiting'], ['agent2', 'proj', 'working'], ['agent3', 'other', 'error'], ['agent4', 'other', 'offline']]) {
|
||||
const sessionsDir = join(root, agent); mkdirSync(sessionsDir);
|
||||
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: 'done? <script>window.injected=true</script>' }] };
|
||||
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: (state === 'waiting' ? 'Input needed: ' : '') + 'done? <script>window.injected=true</script>' }] };
|
||||
const lines = [
|
||||
{ type: 'session', id: agent, timestamp: '2026-09-01T00:00:00Z', cwd: '/fixture/workspace' },
|
||||
{ type: 'message', timestamp: '2026-09-01T00:00:01Z', message: { role: 'user', content: [{ type: 'text', text: 'Build the fixture task' }] } },
|
||||
@@ -23,7 +23,11 @@ export async function fixture() {
|
||||
if (state === 'working') lines.push({ type: 'message', timestamp: '2026-09-01T00:00:03Z', message: { role: 'user', content: [{ type: 'text', text: 'continue' }] } });
|
||||
writeFileSync(join(sessionsDir, 's.jsonl'), lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
||||
specs.push({ agent, project, sessionsDir, tmux: { session: agent } });
|
||||
if (agent === 'agent1') writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
|
||||
if (agent === 'agent1') {
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
|
||||
// #1511: the same path `mosaic seat task --by` takes, on the fixture record only.
|
||||
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
|
||||
}
|
||||
}
|
||||
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
|
||||
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const oldAt = '2026-09-14T10:00:00.000Z', launchAt = '2026-09-15T10:00:00.000Z', newAt = '2026-09-15T10:01:00.000Z';
|
||||
const notice = `relaunched at ${launchAt}, no messages since`;
|
||||
|
||||
test('both presentations replace old activity with relaunch notice, label retained history, then resume after new activity', { timeout: 60000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-'));
|
||||
let board, web, b;
|
||||
try {
|
||||
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 's.jsonl');
|
||||
const line = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
|
||||
const history = line(oldAt, 'Input needed: OLD_FIXTURE_REPLY <img src=x onerror=alert(1)>');
|
||||
writeFileSync(file, history);
|
||||
const seatsDir = join(root, 'seats');
|
||||
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' });
|
||||
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } });
|
||||
const boardURL = `http://127.0.0.1:${board.address().port}`;
|
||||
web = await startServer({ port: 0, board: boardURL });
|
||||
const webURL = `http://127.0.0.1:${web.address().port}`;
|
||||
const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0];
|
||||
assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt);
|
||||
assert.match(row.lastAssistantText, /OLD_FIXTURE_REPLY/); assert.equal(row.state, 'waiting'); assert.equal(row.taskSetBy, 'fixture-setter');
|
||||
b = await browser(); await b.viewport(320, 1000);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
|
||||
await b.navigate(boardURL); await wait('document.querySelector(".session-row")');
|
||||
const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent');
|
||||
assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false);
|
||||
await b.evaluate('document.querySelector(".row-toggle").click()');
|
||||
assert.match(await b.evaluate('document.querySelector(".detail-row").textContent'), /Historical last message.*OLD_FIXTURE_REPLY/s);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("img")'), false);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
|
||||
await b.navigate(webURL); await wait('document.querySelector("table.sessions [data-open]")');
|
||||
assert.ok((await b.evaluate('document.querySelector("table.sessions").textContent')).includes(notice));
|
||||
const card = await b.evaluate('document.querySelector("#waiting").textContent');
|
||||
assert.ok(card.includes(notice)); assert.equal(card.includes('OLD_FIXTURE_REPLY'), false);
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
|
||||
const inspector = await b.evaluate('document.querySelector("#inspection").textContent');
|
||||
assert.ok(inspector.includes(notice)); assert.match(inspector, /Historical last assistant text.*OLD_FIXTURE_REPLY/s);
|
||||
assert.match(inspector, /Historical last activity/); assert.match(inspector, /fixture-setter/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), true, 'reply eligibility unchanged');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("img")'), false);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
|
||||
appendFileSync(file, line(newAt, 'NEW_FIXTURE_REPLY'));
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await wait('document.querySelector("#inspection").textContent.includes("NEW_FIXTURE_REPLY")');
|
||||
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('relaunched at'), false);
|
||||
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), false);
|
||||
await b.navigate(boardURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
|
||||
assert.equal((await b.evaluate('document.querySelector(".session-row").textContent')).includes('relaunched at'), false);
|
||||
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in New Issue
Block a user