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:
@@ -12,10 +12,10 @@ rewritable; they are not run records and are not evidence.
|
||||
| State | Plain-words meaning |
|
||||
|---------|----------------------|
|
||||
| working | The agent is in the middle of a turn: thinking or running a tool. A seat whose newest log entry is a tool call or a tool result is working even if its last words looked like a question. |
|
||||
| waiting | The agent finished its turn with a text-only message. It is your move now. |
|
||||
| waiting | The agent explicitly requested your input in its completed reply. |
|
||||
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
|
||||
| offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. |
|
||||
| idle | The agent is live but has not had a conversation yet. |
|
||||
| idle | The agent is live and available, including after a normal completed reply. |
|
||||
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
|
||||
|
||||
Liveness means a pane in the agent's tmux session is actually running `pi`
|
||||
@@ -23,6 +23,79 @@ Liveness means a pane in the agent's tmux session is actually running `pi`
|
||||
exists but only runs bash or some other program counts as offline, not
|
||||
waiting.
|
||||
|
||||
### Explicit human attention
|
||||
|
||||
A completed assistant reply requests attention only when its first nonblank text
|
||||
line begins at column zero with `Input needed: ` and a nonempty request. Example:
|
||||
|
||||
```text
|
||||
Input needed: Choose staging or production for the approved test.
|
||||
```
|
||||
|
||||
Ordinary replies such as `BOARD_REPLY_OK`, completion reports and questions without
|
||||
this explicit signal are idle. Code/quote examples and thinking blocks do not
|
||||
count. Tool activity/errors retain precedence. A later ordinary completed reply
|
||||
clears the previous request; a user/tool message is working, not waiting.
|
||||
|
||||
Agents reserve the signal for Jason's decision or input, not another agent's
|
||||
review or routine completion. It is display state, never action authorization.
|
||||
Existing unmarked replies cannot establish a human blocker. Seen acknowledges
|
||||
the current event and removes it from the attention list; it does not resolve a
|
||||
genuine request or change waiting to idle. A new request reappears.
|
||||
|
||||
## Relaunch activity notice
|
||||
|
||||
A live native row with a positively live, matching registration gets a
|
||||
`relaunchedAt` timestamp when that registration's valid `startedAt` is strictly
|
||||
newer than valid recorded `lastActivity`. Both board presentations then show
|
||||
`relaunched at X, no messages since` in the current activity/preview positions.
|
||||
The inspector explicitly labels retained last activity, assistant text and errors
|
||||
as historical. CLI `scan --print` also replaces its old preview/age with the notice. New recorded session activity at or after the launch timestamp
|
||||
clears the notice. Equality does not assert a relaunch.
|
||||
|
||||
Missing/invalid timestamps, missing or mismatched registrations, unknown/dead PID
|
||||
or row liveness, and connector rows yield `relaunchedAt: null`. Unknown is not
|
||||
proof of relaunch. The comparison uses the existing session activity timestamp,
|
||||
not a new transcript index or authenticated process-incarnation protocol.
|
||||
|
||||
This field changes presentation only. Historical transcript files and serialized
|
||||
lastActivity/lastAssistantText/lastError remain intact. State, attention, Seen,
|
||||
task selection/attribution and reply eligibility are unchanged. An old unresolved
|
||||
waiting/error state therefore remains visible, with its text labelled historical,
|
||||
rather than being silently cleared by the new notice. No launcher or live
|
||||
registration mutation is required to test this behavior.
|
||||
|
||||
## Discord connector rows
|
||||
|
||||
The CLI discovers private `<dataRoot>/discord/<binding>.json` files on every
|
||||
scan, including server rescans. Only matching safe binding name and seat identity
|
||||
are used. Files must be regular, non-symlink, mode 0600 and at most 1 MiB.
|
||||
Discovery never resolves token paths or projects binding policy, Discord IDs or
|
||||
user/channel lists. Invalid bindings produce fixed, content-free discoveryErrors.
|
||||
Library callers enable this with `discordDataRoot` on scan/startServer.
|
||||
|
||||
Rows use project `fleet` and agent `<seat> (discord: <binding>)`, distinct from
|
||||
native seats. Task is fixed `Discord connector`, with source `connector`, never
|
||||
inferred from the first user message: Discord routing envelopes contain private
|
||||
IDs. Ordinary-agent task derivation and assistant transcript display are unchanged.
|
||||
Session history comes from `sessions/discord-<binding>` without
|
||||
following linked directories/files. Liveness uses the connector's readPid and
|
||||
ownerState checks, not tmux or registration: only a live PID with matching boot
|
||||
ID and start tick is live. Missing, invalid, dead or unverifiable owners are
|
||||
non-live. The `connector` projection contains binding, alive, ownerState and
|
||||
braked only; it does not expose the journal directory or owner record.
|
||||
|
||||
STOP presence is shown separately as braked, even when the owner is offline.
|
||||
An unsafe journal path gives brake unknown, not an unbraked claim. STOP contents
|
||||
are never read. Activity retains the usual idle/waiting/error rules. Both board
|
||||
pages omit Reply for connector rows; the backend refuses connector replies before
|
||||
transport even if a stale/forged native registration supplies tmux details.
|
||||
|
||||
This is read-only observation, not connector control. No brake, unlock, recovery,
|
||||
Discord request, counter collection or engine action is performed. Tests use
|
||||
isolated bindings, sessions and process identities; they do not prove live
|
||||
service transitions or authorize replacing a running board.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
@@ -156,8 +229,18 @@ name alone. A registered task, project or workspace replaces the derived
|
||||
value and the row's source field (`taskSource`, `activeProjectSource`,
|
||||
`workspaceSource`) reads `registration`; the page shows a small source tag
|
||||
next to the value and a "Registered" line in the detail with the start time,
|
||||
harness, pid and tmux session. An empty task or a null project or workspace
|
||||
in the record leaves the derived value in place. Rows with no registration
|
||||
harness, pid and tmux session. When the task shown is the registered one,
|
||||
the row also carries `taskSetBy` (#1511): the record's `taskSetBy` from
|
||||
`mosaic seat task --by NAME` (else `$MOSAIC_AGENT_NAME`, else `unknown`),
|
||||
or `unknown` for a record written before the field existed. Both pages show
|
||||
it as a "set by NAME" tag after the task's source tag and a "Task set by"
|
||||
detail row. It is what the caller claimed, not a verified identity: the
|
||||
board escapes and displays it and nothing else reads it; in particular the
|
||||
reply gate looks only at the registration. For every other task source
|
||||
(`first-user-message`, `connector`, a stale registration, no task) it is
|
||||
null, so a record never lends its setter to a task it did not set. An empty
|
||||
task or a null project or workspace in the record leaves the derived value
|
||||
in place. Rows with no registration
|
||||
are exactly as before. The board only reads `seats/`; `mosaic launch` and
|
||||
`mosaic seat task` are the only writers. A malformed record is listed in
|
||||
`registrationErrors` on the index (and on stderr for `scan`) and skipped.
|
||||
@@ -194,6 +277,7 @@ values), the scan refuses rather than silently dropping every mark.
|
||||
"cwd": "/mnt/storage/src/mosaic-stack",
|
||||
"task": "Read agents/darkwing/work/RESTART.md",
|
||||
"taskSource": "first-user-message",
|
||||
"taskSetBy": null,
|
||||
"workspace": "/mnt/storage/src/mosaic-stack",
|
||||
"workspaceSource": "tmux-pane",
|
||||
"activeProject": "mosaic-stack",
|
||||
|
||||
@@ -42,7 +42,7 @@ async function main() {
|
||||
const boardDir = join(dataRoot, "board");
|
||||
const seatsDir = join(dataRoot, "seats");
|
||||
if (opts.command === "serve") {
|
||||
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir });
|
||||
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
const addr = server.address();
|
||||
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
|
||||
const stop = () => server.close(() => process.exit(0));
|
||||
@@ -50,13 +50,15 @@ async function main() {
|
||||
process.on("SIGTERM", stop);
|
||||
return;
|
||||
}
|
||||
const index = scan(specs, { boardDir, isAlive, seatsDir });
|
||||
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
|
||||
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
|
||||
if (opts.print) {
|
||||
for (const s of index.sessions) {
|
||||
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
|
||||
const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
|
||||
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
|
||||
const age = s.relaunchedAt ? "relaunch" : s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
|
||||
const preview = s.relaunchedAt ? `relaunched at ${s.relaunchedAt}, no messages since` : s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : "";
|
||||
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${preview}\n`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered, ${index.registrationStale.length} stale)\n`);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Read-only connector discovery. Never load binding policy or token resources.
|
||||
import { lstatSync, readdirSync, openSync, closeSync, fstatSync, readFileSync, constants } from 'node:fs';
|
||||
import { join, resolve, parse } from 'node:path';
|
||||
import { readPid, ownerState } from '../../discord/src/journal.mjs';
|
||||
|
||||
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// Check every existing ancestor, not just the final component.
|
||||
export function noLinks(path) {
|
||||
const absolute = resolve(path);
|
||||
let current = parse(absolute).root;
|
||||
for (const part of absolute.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part);
|
||||
try { if (lstatSync(current).isSymbolicLink()) return false; }
|
||||
catch (e) { if (e.code === 'ENOENT') return true; return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function stat(path) { try { return lstatSync(path); } catch { return null; } }
|
||||
export function discoverDiscordAgents(dataRoot) {
|
||||
const specs = [], errors = [];
|
||||
const dir = join(dataRoot, 'discord');
|
||||
if (!noLinks(dir)) return { specs, errors: ['discord discovery: unsafe directory'] };
|
||||
const ds = stat(dir);
|
||||
if (!ds) return { specs, errors };
|
||||
if (!ds.isDirectory()) return { specs, errors: ['discord discovery: invalid directory'] };
|
||||
let names;
|
||||
try { names = readdirSync(dir).sort(); } catch { return { specs, errors: ['discord discovery: unreadable directory'] }; }
|
||||
for (const file of names.filter(n => n.endsWith('.json'))) {
|
||||
let fd;
|
||||
try {
|
||||
const name = file.slice(0, -5);
|
||||
if (!NAME.test(name)) throw new Error();
|
||||
fd = openSync(join(dir, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const s = fstatSync(fd);
|
||||
if (!s.isFile() || (s.mode & 0o7777) !== 0o600 || s.size > 1024 * 1024) throw new Error();
|
||||
const raw = JSON.parse(readFileSync(fd, 'utf8'));
|
||||
if (!raw || raw.name !== name || typeof raw.seat !== 'string' || !NAME.test(raw.seat)) throw new Error();
|
||||
// Do not retain the binding object, even in internal scan specs.
|
||||
specs.push({ agent: `${raw.seat} (discord: ${name})`, project: 'fleet', sessionsDir: join(dataRoot, 'sessions', `discord-${name}`), tmux: null, connector: { binding: name, journalDir: join(dir, name) } });
|
||||
} catch { errors.push('discord discovery: skipped invalid binding'); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
return { specs, errors };
|
||||
}
|
||||
export function inspectDiscord(connector, { identity } = {}) {
|
||||
const dir = connector.journalDir, owner = join(dir, 'run.lock', 'owner.json');
|
||||
let braked = null;
|
||||
if (noLinks(dir)) {
|
||||
try { lstatSync(join(dir, 'STOP')); braked = true; }
|
||||
catch (e) { if (e.code === 'ENOENT') braked = false; }
|
||||
}
|
||||
let ownerStatus = 'invalid';
|
||||
if (noLinks(owner)) {
|
||||
const s = stat(owner);
|
||||
if (!s || s.isFile()) {
|
||||
try { ownerStatus = ownerState(readPid(dir), identity ? { identity } : {}); } catch { ownerStatus = 'invalid'; }
|
||||
}
|
||||
}
|
||||
return { binding: connector.binding, braked, ownerState: ownerStatus, alive: ownerStatus === 'live' };
|
||||
}
|
||||
// Connector session trees must not follow links, including ancestor links.
|
||||
export function newestDiscordSession(dir) {
|
||||
if (!noLinks(dir)) return null;
|
||||
let best = null;
|
||||
const walk = path => {
|
||||
const s = stat(path);
|
||||
if (!s || s.isSymbolicLink()) return;
|
||||
if (s.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); }
|
||||
else if (s.isFile() && path.endsWith('.jsonl') && (!best || s.mtimeMs > best.time)) best = { path, time: s.mtimeMs };
|
||||
};
|
||||
walk(dir);
|
||||
return best?.path ?? null;
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
|
||||
.msg-error{color:var(--danger)}
|
||||
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
|
||||
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
|
||||
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.unknown{color:var(--muted);font-style:italic}
|
||||
@@ -71,6 +72,7 @@
|
||||
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
|
||||
.reply-ok{color:var(--muted)}
|
||||
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.detail-row td{background:var(--raised)}
|
||||
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
|
||||
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
||||
@@ -194,6 +196,12 @@
|
||||
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
|
||||
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
|
||||
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
|
||||
// Who set a registered task (#1511): shown only when the task on the row is
|
||||
// the registered one (the scan gives null otherwise). It is what the
|
||||
// caller claimed when it ran `mosaic seat task`, not a verified identity,
|
||||
// and it never changes what the row lets you do.
|
||||
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
|
||||
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
|
||||
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
|
||||
// hover title. modelText() already escapes, so the title takes it as is.
|
||||
// Empty when the log has not named one yet.
|
||||
@@ -205,6 +213,7 @@
|
||||
var replyReceipts = {};
|
||||
var replyDrafts = {};
|
||||
function canReply(rec) {
|
||||
if (rec.connector) return false;
|
||||
var reg = rec.registered;
|
||||
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
|
||||
}
|
||||
@@ -216,6 +225,7 @@
|
||||
}
|
||||
function replyControl(rec) {
|
||||
var id = [rec.project, rec.agent].join("/");
|
||||
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
|
||||
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
|
||||
return '<form class="reply-form" data-id="' + esc(id) + '">' +
|
||||
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
|
||||
@@ -233,12 +243,14 @@
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
|
||||
function buildRowPair(rec, showProject) {
|
||||
var idx = rowIdx++;
|
||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||
var open = !!openDetails[key];
|
||||
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
|
||||
var msg = rec.state === "error" && rec.lastError
|
||||
var notice = relaunchNotice(rec);
|
||||
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
|
||||
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||
@@ -246,7 +258,7 @@
|
||||
// from the log and tmux, else "unknown". Never guessed. The source tag on
|
||||
// the row says which one is shown.
|
||||
var task = rec.task
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
|
||||
: '<span class="unknown">unknown</span>';
|
||||
var activeProject = rec.activeProject
|
||||
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
|
||||
@@ -255,8 +267,8 @@
|
||||
'<tr class="' + cls + '">' +
|
||||
projectCell +
|
||||
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
|
||||
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
|
||||
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
|
||||
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
|
||||
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
|
||||
"<td>" + task + "</td>" +
|
||||
"<td>" + activeProject + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
@@ -271,6 +283,7 @@
|
||||
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
|
||||
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
|
||||
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
|
||||
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
|
||||
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
|
||||
@@ -278,11 +291,12 @@
|
||||
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
|
||||
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
|
||||
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
||||
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
||||
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"</dl></td></tr>";
|
||||
return main + detail;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
//
|
||||
// States (plain words):
|
||||
// working - the agent is in the middle of a turn (thinking or running tools)
|
||||
// waiting - the agent finished its turn; it is your move
|
||||
// waiting - the agent explicitly requests human input in its completed reply
|
||||
// error - the agent's last turn ended in an error, was aborted, or was cut off; look at it
|
||||
// offline - no tmux session for this agent, or its session no longer runs pi
|
||||
// idle - the agent is live but has no conversation yet
|
||||
// idle - the agent is available, with no explicit input request
|
||||
// unknown - liveness could not be checked (tmux missing or unresponsive); not a guess
|
||||
//
|
||||
// Board files are derived and rewritable. They are not run records. The one
|
||||
@@ -19,6 +19,8 @@ import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
|
||||
|
||||
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
|
||||
|
||||
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
|
||||
const TEXT_LIMIT = 240;
|
||||
|
||||
@@ -137,12 +139,20 @@ export function hasToolCall(message) {
|
||||
return Array.isArray(message?.content) && message.content.some((c) => c && c.type === "toolCall");
|
||||
}
|
||||
|
||||
// Pure state rule. alive: true/false, or null when liveness could not be checked.
|
||||
// A null check is reported as "unknown" rather than assumed alive (fail closed).
|
||||
// Acceptance rule (plan page, 2026-09-12): a seat mid-tool-call is working,
|
||||
// never waiting. The newest entry being an assistant message with a tool call,
|
||||
// or a tool result, means working even if the last text looked like a question.
|
||||
// waiting needs a text-only assistant message whose turn ended (stopReason stop).
|
||||
// Explicit display convention, not a language classifier or control permission.
|
||||
// Only a plain first nonblank text line can request human input. Quoted/code
|
||||
// examples and thinking blocks must not create attention events.
|
||||
function requestsHumanInput(message) {
|
||||
if (!Array.isArray(message.content)) return false;
|
||||
const text = message.content.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text).join("\n");
|
||||
const first = text.split(/\r?\n/).find((line) => line.trim()) ?? "";
|
||||
const prefix = "Input needed: ";
|
||||
return first.startsWith(prefix) && first.slice(prefix.length).trim().length > 0;
|
||||
}
|
||||
|
||||
// A missing liveness check stays unknown. Offline/errors/tool activity take
|
||||
// precedence over attention text. Completion alone means idle, not waiting.
|
||||
export function deriveState({ alive, session }) {
|
||||
if (alive === false) return "offline";
|
||||
if (alive !== true) return "unknown";
|
||||
@@ -151,7 +161,7 @@ export function deriveState({ alive, session }) {
|
||||
if (m.role === "assistant") {
|
||||
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error";
|
||||
if (hasToolCall(m)) return "working";
|
||||
if (m.stopReason === "stop") return "waiting";
|
||||
if (m.stopReason === "stop") return requestsHumanInput(m) ? "waiting" : "idle";
|
||||
return "working";
|
||||
}
|
||||
return "working";
|
||||
@@ -317,9 +327,10 @@ export function pidAlive(pid) {
|
||||
// it is still reported under `registered` (with alive false) but the derived
|
||||
// values win, because the record describes a launch that is not running.
|
||||
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null, isPidAlive = pidAlive } = {}) {
|
||||
const live = liveness(isAlive(spec.tmux));
|
||||
const connector = spec.connector ? inspectDiscord(spec.connector) : null;
|
||||
const live = connector ? { alive: connector.alive, workspace: null } : liveness(isAlive(spec.tmux));
|
||||
const alive = live.alive;
|
||||
const file = findNewestSession(spec.sessionsDir);
|
||||
const file = connector ? newestDiscordSession(spec.sessionsDir) : findNewestSession(spec.sessionsDir);
|
||||
const session = file ? readSession(file) : null;
|
||||
const state = deriveState({ alive, session });
|
||||
const scannedAt = now();
|
||||
@@ -328,8 +339,16 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
const needsYou = state === "waiting" || state === "error";
|
||||
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
|
||||
const cwd = session?.cwd ?? null;
|
||||
const record = registration && typeof registration === "object" ? registration : null;
|
||||
const record = !connector && registration && typeof registration === "object" ? registration : null;
|
||||
const registeredAlive = record ? isPidAlive(record.pid) : null;
|
||||
// Presentation only: retain history and attention/Seen semantics. Unknown
|
||||
// liveness, unmatched records and unknown timestamps cannot assert relaunch.
|
||||
const launchTime = typeof record?.startedAt === "string" ? Date.parse(record.startedAt) : NaN;
|
||||
const activityTime = typeof lastActivity === "string" ? Date.parse(lastActivity) : NaN;
|
||||
const relaunchedAt = alive === true && registeredAlive === true &&
|
||||
samePath(record?.sessionsDir, spec.sessionsDir) && Number.isFinite(launchTime) &&
|
||||
Number.isFinite(activityTime) && launchTime > activityTime
|
||||
? new Date(launchTime).toISOString() : null;
|
||||
const reg = record && registeredAlive !== false ? record : null;
|
||||
const derivedWorkspace = live.workspace ?? cwd;
|
||||
const workspace = reg?.workspace ?? derivedWorkspace;
|
||||
@@ -339,11 +358,21 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
const activeProject = reg?.project ?? derivedProject;
|
||||
const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null;
|
||||
const firstUserText = session?.firstUserText ?? null;
|
||||
const task = reg?.task ? reg.task : firstUserText;
|
||||
const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
|
||||
// Discord user text starts with a routing envelope containing private IDs.
|
||||
// Connector task metadata is fixed, never inferred from that transcript.
|
||||
const task = connector ? "Discord connector" : reg?.task ? reg.task : firstUserText;
|
||||
const taskSource = connector ? "connector" : reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
|
||||
// Who set the task (#1511), only when the task shown *is* the registered
|
||||
// one: a record written before the field existed reads "unknown"; a
|
||||
// transcript-derived task, a stale registration and a connector's fixed
|
||||
// task carry no attribution at all (null), so a record can never lend its
|
||||
// setter to a task it did not set. It is the caller's claim, bounded by
|
||||
// the seat package on read, and it grants nothing.
|
||||
const taskSetBy = taskSource === "registration" ? (reg.taskSetBy ?? "unknown") : null;
|
||||
return {
|
||||
agent: spec.agent,
|
||||
project: spec.project,
|
||||
...(connector ? { connector } : {}),
|
||||
state,
|
||||
waitingOnYou: needsYou && !isSeen,
|
||||
seen: isSeen,
|
||||
@@ -359,6 +388,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
provider: session?.provider ?? null,
|
||||
task,
|
||||
taskSource,
|
||||
taskSetBy,
|
||||
workspace,
|
||||
workspaceSource,
|
||||
activeProject,
|
||||
@@ -367,6 +397,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
? { startedAt: record.startedAt, updatedAt: record.updatedAt, harness: record.harness, pid: record.pid, alive: registeredAlive, tmux: record.tmux, layout: record.layout, launchScript: record.launchScript }
|
||||
: null,
|
||||
lastActivity,
|
||||
relaunchedAt,
|
||||
ageSeconds,
|
||||
lastAssistantText: session?.lastAssistantText ?? null,
|
||||
lastError: session?.lastError ?? null,
|
||||
@@ -403,12 +434,13 @@ function writeAtomic(path, data) {
|
||||
|
||||
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
|
||||
// seatsDir (optional): where `mosaic launch` registrations live; read only.
|
||||
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive } = {}) {
|
||||
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive, discordDataRoot = null } = {}) {
|
||||
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
|
||||
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
|
||||
const seen = loadSeen(boardDir);
|
||||
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
|
||||
const discord = discordDataRoot ? discoverDiscordAgents(discordDataRoot) : { specs: [], errors: [] };
|
||||
const records = [...specs, ...discord.specs].map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
|
||||
for (const rec of records) {
|
||||
const dir = join(boardDir, "sessions", rec.project);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -423,6 +455,7 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
|
||||
registered: records.filter((r) => r.registered).map(seenKey),
|
||||
registrationStale: records.filter((r) => r.registered && r.registered.alive === false).map(seenKey),
|
||||
registrationErrors,
|
||||
discoveryErrors: discord.errors,
|
||||
sessions: records,
|
||||
};
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -53,6 +53,7 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e
|
||||
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
|
||||
const rec = index.sessions.find((r) => seenKey(r) === key);
|
||||
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
|
||||
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
|
||||
const reg = rec.registered;
|
||||
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
|
||||
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
|
||||
@@ -130,8 +131,8 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
}
|
||||
|
||||
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive });
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
|
||||
return createHttpServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method === "POST" && url.pathname === "/api/reply") {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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 { scan, markSeen, deriveState } from '../src/scan.mjs';
|
||||
|
||||
test('explicit request, Seen, ordinary completion and a new request have distinct attention states', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-attention-flow-'));
|
||||
try {
|
||||
const sessionsDir = join(root, 'sessions');
|
||||
mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 's.jsonl');
|
||||
writeFileSync(file, JSON.stringify({ type: 'session', id: 'fixture', cwd: root, timestamp: '2026-09-13T00:00:00Z' }) + '\n');
|
||||
const specs = [{ agent: 'researcher', project: 'fixture', sessionsDir, tmux: {} }];
|
||||
const boardDir = join(root, 'board');
|
||||
const get = () => scan(specs, { boardDir, isAlive: () => true }).sessions[0];
|
||||
const append = (second, text) => appendFileSync(file, JSON.stringify({
|
||||
type: 'message', timestamp: `2026-09-13T00:00:0${second}Z`,
|
||||
message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] },
|
||||
}) + '\n');
|
||||
append(1, 'Input needed: Choose A or B.');
|
||||
let r = get();
|
||||
assert.equal(r.state, 'waiting');
|
||||
assert.equal(r.waitingOnYou, true);
|
||||
markSeen(boardDir, { project: 'fixture', agent: 'researcher', lastActivity: r.lastActivity, seen: true });
|
||||
r = get();
|
||||
assert.equal(r.state, 'waiting', 'acknowledgment does not resolve an input request');
|
||||
assert.equal(r.seen, true);
|
||||
assert.equal(r.waitingOnYou, false);
|
||||
append(2, 'BOARD_REPLY_OK');
|
||||
r = get();
|
||||
assert.equal(r.state, 'idle');
|
||||
assert.equal(r.waitingOnYou, false);
|
||||
assert.equal(r.seen, false, 'ordinary completion needs no Seen action');
|
||||
append(3, 'Input needed: Confirm the revised choice.');
|
||||
r = get();
|
||||
assert.equal(r.state, 'waiting');
|
||||
assert.equal(r.waitingOnYou, true, 'old Seen does not suppress a new request');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('attention convention ignores reasoning/quoted examples and permits leading blank lines', () => {
|
||||
const derive = (content, alive = true) => deriveState({ alive, session: { lastMessage: { role: 'assistant', stopReason: 'stop', content } } });
|
||||
const text = (value) => [{ type: 'text', text: value }];
|
||||
for (const value of ['> Input needed: Choose A.', ' Input needed: Choose A.', '`Input needed: Choose A.`', 'Input needed:\tChoose A.']) {
|
||||
assert.equal(derive(text(value)), 'idle', value);
|
||||
}
|
||||
assert.equal(derive([{ type: 'thinking', thinking: 'Input needed: Choose A.' }, ...text('Done.')]), 'idle');
|
||||
assert.equal(derive(text('\n\r\nInput needed: Choose A.')), 'waiting');
|
||||
assert.equal(derive(text('Input needed: Choose A.'), false), 'offline');
|
||||
assert.equal(derive(text('Input needed: Choose A.'), null), 'unknown');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deriveState } from '../src/scan.mjs';
|
||||
|
||||
const state = (text, overrides = {}) => deriveState({ alive: true, session: {
|
||||
lastMessage: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }], ...overrides },
|
||||
} });
|
||||
|
||||
test('completed smoke replies and ordinary questions are idle, not human blockers', () => {
|
||||
for (const text of ['BOARD_REPLY_OK', 'RESEARCHER_NATIVE_SMOKE_OK', 'Done.', 'Shall I continue?']) {
|
||||
assert.equal(state(text), 'idle', text);
|
||||
}
|
||||
});
|
||||
|
||||
test('only an explicit first-line input request makes a finished reply waiting', () => {
|
||||
assert.equal(state('Input needed: Choose the release target.\nDetails follow.'), 'waiting');
|
||||
for (const text of ['Input needed:', 'Input needed: ', 'Example:\nInput needed: Choose A.', '```\nInput needed: Choose A.\n```']) {
|
||||
assert.equal(state(text), 'idle', text);
|
||||
}
|
||||
});
|
||||
|
||||
test('tool activity, user text, errors and unfinished turns override attention text', () => {
|
||||
const text = 'Input needed: Choose A.';
|
||||
assert.equal(state(text, { content: [{ type: 'text', text }, { type: 'toolCall', name: 'read' }] }), 'working');
|
||||
assert.equal(state(text, { role: 'user' }), 'working');
|
||||
assert.equal(state(text, { role: 'toolResult' }), 'working');
|
||||
assert.equal(state(text, { stopReason: 'error' }), 'error');
|
||||
assert.equal(state(text, { stopReason: undefined }), 'working');
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
|
||||
import { scan, scanAgent } from '../src/scan.mjs';
|
||||
import { replyToRow, startServer } from '../src/serve.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
import { envelope } from '../../discord/src/context.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
|
||||
test('STOP access failure is unknown, not absence, under a non-root identity', () => {
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { inspectDiscord } from ${JSON.stringify(new URL('../src/discord.mjs', import.meta.url).href)};
|
||||
assert.notEqual(process.getuid(), 0, 'permission regression must not run as root');
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-stop-permission-'));
|
||||
const dir = join(root, 'journal'), connector = { binding: 'fixture', journalDir: dir };
|
||||
mkdirSync(dir);
|
||||
try {
|
||||
assert.equal(inspectDiscord(connector).braked, false, 'verified absence');
|
||||
writeFileSync(join(dir, 'STOP'), 'private contents never read');
|
||||
assert.equal(inspectDiscord(connector).braked, true, 'verified presence');
|
||||
chmodSync(dir, 0o000);
|
||||
const inaccessible = inspectDiscord(connector);
|
||||
assert.equal(inaccessible.braked, null, 'inaccessible STOP is unknown');
|
||||
assert.equal(inaccessible.alive, false);
|
||||
chmodSync(dir, 0o700);
|
||||
assert.equal(inspectDiscord(connector).braked, true, 'restored access sees STOP');
|
||||
rmSync(join(dir, 'STOP'));
|
||||
assert.equal(inspectDiscord(connector).braked, false, 'verified removal');
|
||||
} finally { chmodSync(dir, 0o700); rmSync(root, { recursive: true, force: true }); }
|
||||
`], { encoding: 'utf8', timeout: 10000, ...(process.getuid() === 0 ? { uid: 65534, gid: 65534 } : {}) });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
});
|
||||
|
||||
test('connector Task never inherits Discord routing envelopes; ordinary Task still uses user text', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const text = envelope({ guildName: 'Synthetic guild', channelName: 'synthetic-channel', authorId: '111111111111111111', messageId: '222222222222222222', text: 'Synthetic user request' });
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo');
|
||||
writeFileSync(join(sessionsDir, 's.jsonl'), JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n');
|
||||
const connector = scanAgent(discoverDiscordAgents(f.root).specs[0]);
|
||||
assert.equal(connector.task, 'Discord connector');
|
||||
assert.equal(connector.taskSource, 'connector');
|
||||
for (const secret of ['111111111111111111', '222222222222222222', 'Synthetic user request']) assert.equal(connector.task.includes(secret), false);
|
||||
const ordinary = scanAgent({ agent: 'native', project: 'repo', sessionsDir, tmux: {} }, { isAlive: () => true });
|
||||
assert.equal(ordinary.task, text.replace(/\s+/g, ' ').trim());
|
||||
assert.equal(ordinary.taskSource, 'first-user-message');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-discord-'));
|
||||
mkdirSync(join(root, 'discord', 'demo', 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(root, 'discord', 'demo.json'), JSON.stringify({ name: 'demo', seat: 'researcher', tokenFile: '/never-read-secret', guildId: 'PRIVATE-ID', users: ['PRIVATE-USER'] }), { mode: 0o600 });
|
||||
const sessions = join(root, 'sessions', 'discord-demo'); 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: 'done' }] } }) + '\n');
|
||||
const owner = join(root, 'discord', 'demo', 'run.lock', 'owner.json');
|
||||
writeFileSync(owner, JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
return { root, owner, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const result = discoverDiscordAgents(f.root);
|
||||
assert.equal(result.specs.length, 1); assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.specs[0].agent, 'researcher (discord: demo)');
|
||||
assert.equal(JSON.stringify(result).includes('PRIVATE'), false); assert.equal(JSON.stringify(result).includes('tokenFile'), false);
|
||||
writeFileSync(join(f.root, 'discord', 'bad.json'), '{"name":"bad","seat":"../../escape","secret":"PRIVATE"}', { mode: 0o600 });
|
||||
symlinkSync(join(f.root, 'discord', 'demo.json'), join(f.root, 'discord', 'linked.json'));
|
||||
writeFileSync(join(f.root, 'discord', 'other.json'), '{"name":"wrong","seat":"x"}', { mode: 0o600 });
|
||||
chmodSync(join(f.root, 'discord', 'demo.json'), 0o644);
|
||||
const bad = discoverDiscordAgents(f.root); assert.equal(bad.specs.length, 0); assert.equal(bad.errors.length, 4);
|
||||
assert.equal(JSON.stringify(bad).includes('PRIVATE'), false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('canonical owner identity and STOP are independent; no tmux fallback or forged registration', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const spec = discoverDiscordAgents(f.root).specs[0];
|
||||
const row = () => scanAgent(spec, { isAlive: () => { throw Error('must not ask tmux'); }, registration: { pid: process.pid, task: 'forged', taskSetBy: 'forged-setter', tmux: { session: 'forged' } } });
|
||||
assert.equal(row().alive, true); assert.equal(row().state, 'idle'); assert.equal(row().registered, null);
|
||||
// #1511: the connector's fixed task borrows no attribution from a native registration.
|
||||
assert.equal(row().task, 'Discord connector'); assert.equal(row().taskSource, 'connector'); assert.equal(row().taskSetBy, null);
|
||||
assert.equal(JSON.stringify(row()).includes('forged-setter'), false);
|
||||
writeFileSync(join(f.root, 'discord', 'demo', 'STOP'), 'PRIVATE BRAKE CONTENT');
|
||||
assert.equal(row().connector.braked, true); assert.equal(row().state, 'idle');
|
||||
assert.equal(JSON.stringify(row()).includes('PRIVATE'), false);
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: '1', boot: identityOf(process.pid).boot }) }).alive, false);
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: null, boot: null }) }).ownerState, 'unknown');
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: identityOf(process.pid).start, boot: '00000000-0000-0000-0000-000000000000' }) }).ownerState, 'mismatch');
|
||||
for (const value of ['broken PRIVATE', JSON.stringify({ pid: process.pid }), JSON.stringify({ pid: 2147483647, ...identityOf(process.pid) })]) {
|
||||
writeFileSync(f.owner, value); assert.equal(row().alive, false); assert.equal(row().state, 'offline');
|
||||
}
|
||||
rmSync(f.owner); assert.equal(row().connector.ownerState, 'absent');
|
||||
symlinkSync('/never-read-secret', f.owner); assert.equal(row().alive, false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector reply refusal precedes forged live tmux registration; ordinary agent still sends', () => {
|
||||
let calls = 0; const exec = () => { calls++; return { status: 0, stdout: '', stderr: '' }; };
|
||||
const r = { agent: 'researcher (discord: demo)', project: 'fleet', connector: { binding: 'demo' }, registered: { alive: true, tmux: { session: 'forged' } } };
|
||||
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
|
||||
delete r.connector;
|
||||
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
|
||||
assert.equal(calls, 0);
|
||||
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
|
||||
const f = fixture(); let server;
|
||||
try {
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
|
||||
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
let response = await fetch(base + '/api/board'); const board = await response.json();
|
||||
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
|
||||
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
|
||||
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
|
||||
assert.equal(response.status, 409);
|
||||
rmSync(join(f.root, 'discord', 'demo.json'));
|
||||
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
|
||||
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector session links and linked directories are not read', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const sessions = join(f.root, 'sessions', 'discord-demo'); rmSync(sessions, { recursive: true });
|
||||
symlinkSync(join(f.root, 'discord'), sessions);
|
||||
const result = scan([], { boardDir: join(f.root, 'board'), discordDataRoot: f.root });
|
||||
assert.equal(result.sessions[0].sessionFile, null);
|
||||
rmSync(join(f.root, 'discord'), { recursive: true }); symlinkSync('/never-read-secret', join(f.root, 'discord'));
|
||||
assert.equal(discoverDiscordAgents(f.root).specs.length, 0);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { scanAgent } from '../src/scan.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
|
||||
const oldAt = '2026-09-14T10:00:00.000Z';
|
||||
const launchAt = '2026-09-15T10:00:00.000Z';
|
||||
const newAt = '2026-09-15T10:01:00.000Z';
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-relaunch-'));
|
||||
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 'session.jsonl');
|
||||
const message = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
|
||||
writeFileSync(file, message(oldAt, 'Input needed: Historical request.'));
|
||||
const spec = { agent: 'fixture', project: 'repo', sessionsDir, tmux: {} };
|
||||
const registration = { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Registered task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' };
|
||||
const read = (extra = {}) => scanAgent(spec, { isAlive: () => true, isPidAlive: () => true, registration, now: () => new Date(newAt), ...extra });
|
||||
return { root, file, spec, registration, read, message, close: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const original = readFileSync(f.file, 'utf8');
|
||||
const row = f.read();
|
||||
assert.equal(row.relaunchedAt, launchAt);
|
||||
assert.equal(row.lastActivity, oldAt); assert.equal(row.lastAssistantText, 'Input needed: Historical request.');
|
||||
assert.equal(row.state, 'waiting'); assert.equal(row.waitingOnYou, true);
|
||||
assert.equal(row.taskSetBy, 'fixture-setter'); assert.equal(row.taskSource, 'registration');
|
||||
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).seen, true);
|
||||
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).waitingOnYou, false);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), original, 'scanning never rewrites historical messages');
|
||||
appendFileSync(f.file, f.message(newAt, 'Fresh completion.'));
|
||||
const next = f.read(); assert.equal(next.relaunchedAt, null); assert.equal(next.state, 'idle');
|
||||
assert.equal(next.lastAssistantText, 'Fresh completion.'); assert.equal(next.lastActivity, newAt);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('CLI print uses the relaunch notice instead of old current preview', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const state = join(f.root, '.pi', 'state', 'fixture'); mkdirSync(state, { recursive: true });
|
||||
const sessionsDir = join(state, 'sessions'); renameSync(f.spec.sessionsDir, sessionsDir);
|
||||
const dataRoot = join(f.root, 'data');
|
||||
writeRegistration(join(dataRoot, 'seats'), { ...f.registration, sessionsDir });
|
||||
const config = join(f.root, 'config.json'); writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
const run = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'scan', '--config', config, '--repo', f.root, '--fleet', 'none', '--liveness', 'assume-alive', '--print'], { encoding: 'utf8', timeout: 10000 });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.ok(run.stdout.includes(`relaunched at ${launchAt}, no messages since`));
|
||||
assert.equal(run.stdout.includes('Historical request.'), false);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('connector owner and fixed task never inherit a native relaunch notice', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const journalDir = join(f.root, 'journal'); mkdirSync(join(journalDir, 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(journalDir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
const row = scanAgent({ ...f.spec, connector: { binding: 'fixture', journalDir } }, { registration: f.registration, isAlive: () => { throw Error('connector cannot use native liveness'); } });
|
||||
assert.equal(row.alive, true); assert.equal(row.relaunchedAt, null); assert.equal(row.task, 'Discord connector');
|
||||
assert.equal(row.taskSetBy, null); assert.equal(row.registered, null);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
assert.equal(f.read({ registration: { ...f.registration, startedAt: oldAt } }).relaunchedAt, null);
|
||||
for (const value of [false, null]) assert.equal(f.read({ isPidAlive: () => value }).relaunchedAt, null);
|
||||
for (const value of [false, null]) assert.equal(f.read({ isAlive: () => value }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: null }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: { ...f.registration, sessionsDir: join(f.root, 'different') } }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: { ...f.registration, startedAt: 'invalid' } }).relaunchedAt, null);
|
||||
writeFileSync(f.file, f.message('invalid', 'Undated history.'));
|
||||
assert.equal(f.read().relaunchedAt, null);
|
||||
writeFileSync(f.file, JSON.stringify({ type: 'message', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'No timestamp.' }] } }) + '\n');
|
||||
assert.equal(f.read().relaunchedAt, null);
|
||||
rmSync(f.file); assert.equal(f.read().relaunchedAt, null);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
@@ -284,13 +284,13 @@ test("deriveState: full state table", () => {
|
||||
{ name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" },
|
||||
{ name: "no session is idle", input: { alive: true, session: null }, expected: "idle" },
|
||||
{ name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" },
|
||||
{ name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" },
|
||||
{ name: "assistant+stop is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "idle" },
|
||||
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
|
||||
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
|
||||
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
|
||||
{ name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" },
|
||||
{ name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" },
|
||||
{ name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" },
|
||||
{ name: "assistant+stop with thinking and text (no tool call) is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "idle" },
|
||||
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
|
||||
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
|
||||
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
|
||||
@@ -335,7 +335,7 @@ test("rule: newest entry is a tool result with no assistant text after it is wor
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => {
|
||||
test("rule: a finished ordinary turn is idle, even if it says your move", () => {
|
||||
const root = makeRoot();
|
||||
writeSessionFile(join(root, "sessions"), "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
|
||||
@@ -344,8 +344,8 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa
|
||||
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }),
|
||||
]);
|
||||
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
|
||||
assert.equal(rec.state, "waiting");
|
||||
assert.equal(rec.waitingOnYou, true);
|
||||
assert.equal(rec.state, "idle");
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -764,7 +764,7 @@ test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", ()
|
||||
mkdirSync(errorDir, { recursive: true });
|
||||
writeSessionFile(waitingDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
writeSessionFile(workingDir, "s.jsonl", [
|
||||
sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }),
|
||||
@@ -866,7 +866,7 @@ test("scan: writes per-agent files and index.json, rerun overwrites, no leftover
|
||||
sessionPath,
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
|
||||
@@ -938,7 +938,7 @@ test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes
|
||||
join(sessionsDir, "s.jsonl"),
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
|
||||
@@ -1131,7 +1131,7 @@ test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
@@ -1146,7 +1146,7 @@ test("scanAgent: a stale mark (agent wrote something newer) is not seen and wait
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
@@ -1197,7 +1197,7 @@ test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewr
|
||||
const lastActivity = "2026-09-01T00:00:01Z";
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
mkdirSync(boardDir, { recursive: true });
|
||||
const seenPath = join(boardDir, "seen.json");
|
||||
@@ -1219,3 +1219,87 @@ test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", ()
|
||||
writeFile(join(boardDir, "seen.json"), "{ bad json");
|
||||
assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task attribution (#1511): taskSetBy on the row, tied to the task source.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function attributionFixture(root, { taskSetBy, task = "registered task", pid = process.pid } = {}) {
|
||||
const sessionsDir = join(root, "sessions");
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
|
||||
]);
|
||||
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
|
||||
const base = makeRegistration({
|
||||
resolved: { seat: "a", project: "rp", sessionsDir, seatDir: join(root, "seat-dir"), launchScript: join(root, "seat-dir", "launch.sh"), layout: "repo", defaultWorkspace: "/w" },
|
||||
task, tmux: { socket: null, session: "a" }, pid, now: () => new Date("2026-09-12T13:00:00Z"),
|
||||
});
|
||||
const registration = taskSetBy === undefined ? base : { ...base, taskSetBy };
|
||||
return { spec, sessionsDir, registration };
|
||||
}
|
||||
|
||||
test("taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered", () => {
|
||||
const named = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const rec = scanAgent(named.spec, { isAlive: () => true, now: GATE_NOW, registration: named.registration });
|
||||
assert.equal(rec.taskSource, "registration");
|
||||
assert.equal(rec.taskSetBy, "darkwing");
|
||||
assert.equal("taskSetBy" in rec.registered, false, "attribution lives on the row next to taskSource only");
|
||||
|
||||
const old = attributionFixture(makeRoot());
|
||||
assert.equal("taskSetBy" in old.registration, false);
|
||||
const oldRec = scanAgent(old.spec, { isAlive: () => true, now: GATE_NOW, registration: old.registration });
|
||||
assert.equal(oldRec.taskSource, "registration");
|
||||
assert.equal(oldRec.taskSetBy, "unknown");
|
||||
|
||||
const literal = attributionFixture(makeRoot(), { taskSetBy: "unknown" });
|
||||
assert.equal(scanAgent(literal.spec, { isAlive: () => true, now: GATE_NOW, registration: literal.registration }).taskSetBy, "unknown");
|
||||
});
|
||||
|
||||
test("taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present", () => {
|
||||
const none = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const withoutReg = scanAgent(none.spec, { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(withoutReg.taskSource, "first-user-message");
|
||||
assert.equal(withoutReg.taskSetBy, null);
|
||||
assert.ok("taskSetBy" in withoutReg);
|
||||
|
||||
const empty = attributionFixture(makeRoot(), { taskSetBy: "darkwing", task: "" });
|
||||
const emptyRec = scanAgent(empty.spec, { isAlive: () => true, now: GATE_NOW, registration: empty.registration });
|
||||
assert.equal(emptyRec.task, "derived task");
|
||||
assert.equal(emptyRec.taskSource, "first-user-message");
|
||||
assert.equal(emptyRec.taskSetBy, null, "a setter on a record whose task is empty is not attached to the transcript's task");
|
||||
assert.notEqual(emptyRec.registered, null);
|
||||
|
||||
const stale = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const staleRec = scanAgent(stale.spec, { isAlive: () => true, now: GATE_NOW, registration: stale.registration, isPidAlive: () => false });
|
||||
assert.equal(staleRec.task, "derived task");
|
||||
assert.equal(staleRec.taskSource, "first-user-message");
|
||||
assert.equal(staleRec.taskSetBy, null, "a stale registration lends neither its task nor its setter");
|
||||
assert.equal(staleRec.registered.alive, false);
|
||||
|
||||
const noSession = scanAgent({ agent: "b", project: "p", sessionsDir: join(makeRoot(), "missing"), tmux: {} }, { isAlive: () => false, now: GATE_NOW });
|
||||
assert.equal(noSession.task, null);
|
||||
assert.equal(noSession.taskSetBy, null);
|
||||
});
|
||||
|
||||
test("taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value", () => {
|
||||
const root = makeRoot();
|
||||
const seatsDir = join(root, "seats");
|
||||
const boardDir = join(root, "board");
|
||||
const f = attributionFixture(root, { taskSetBy: "jason@host:1" });
|
||||
writeRegistration(seatsDir, f.registration);
|
||||
const index = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(index.sessions[0].taskSetBy, "jason@host:1");
|
||||
assert.equal(JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8")).taskSetBy, "jason@host:1");
|
||||
assert.deepEqual(index.registrationErrors, []);
|
||||
|
||||
// A hand-edited record with markup in the field does not reach the board.
|
||||
writeFileSync(join(seatsDir, "repo", "a", "registration.json"), JSON.stringify({ ...f.registration, taskSetBy: "<img src=x onerror=alert(1)>" }));
|
||||
const rejected = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(rejected.sessions[0].taskSource, "first-user-message");
|
||||
assert.equal(rejected.sessions[0].taskSetBy, null);
|
||||
assert.equal(rejected.sessions[0].registered, null);
|
||||
assert.equal(rejected.registrationErrors.length, 1);
|
||||
assert.match(rejected.registrationErrors[0], /taskSetBy/);
|
||||
assert.equal(JSON.stringify(rejected).includes("onerror"), false, "the offending value is not echoed anywhere on the index");
|
||||
});
|
||||
|
||||
@@ -176,8 +176,8 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
|
||||
assert.equal(first.headers.get("content-type"), "application/json");
|
||||
assert.equal(first.headers.get("cache-control"), "no-store");
|
||||
const firstBody = await first.json();
|
||||
assert.equal(firstBody.sessions[0].state, "waiting");
|
||||
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
assert.equal(firstBody.sessions[0].state, "idle");
|
||||
assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
|
||||
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
|
||||
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
|
||||
@@ -397,7 +397,7 @@ test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false
|
||||
const sessionsDir = join(root, "sessions");
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const boardDir = join(root, "board");
|
||||
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
||||
@@ -557,7 +557,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
|
||||
join(sessionsDir, "s.jsonl"),
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
const boardDir = join(dataRoot, "board");
|
||||
@@ -881,3 +881,64 @@ test("page.html: the reply box appears only where canReply() holds (live registr
|
||||
assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
|
||||
assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task attribution (#1511): serialized by the server, rendered by page.html.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("startServer: /api/board carries taskSetBy from a live registration and null for the derived rows", async () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
const derivedDir = join(root, "derived-sessions");
|
||||
for (const dir of [sessionsDir, derivedDir]) {
|
||||
writeSessionFile(dir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
|
||||
]);
|
||||
}
|
||||
const seatsDir = join(root, "seats");
|
||||
const reg = makeRegistration({
|
||||
resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
|
||||
task: "registered task",
|
||||
});
|
||||
writeRegistration(seatsDir, { ...reg, taskSetBy: "darkwing" });
|
||||
const specs = [
|
||||
{ agent: "agent1", project: "proj", sessionsDir, tmux: {} },
|
||||
{ agent: "agent2", project: "proj", sessionsDir: derivedDir, tmux: {} },
|
||||
];
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(root, "board"), seatsDir, isAlive: () => ({ alive: true, workspace: null }), page: "<html></html>" });
|
||||
try {
|
||||
const body = await (await fetch(`http://127.0.0.1:${server.address().port}/api/board`)).json();
|
||||
const byAgent = Object.fromEntries(body.sessions.map((r) => [r.agent, r]));
|
||||
assert.equal(byAgent.agent1.taskSource, "registration");
|
||||
assert.equal(byAgent.agent1.taskSetBy, "darkwing");
|
||||
assert.equal(byAgent.agent2.taskSource, "first-user-message");
|
||||
assert.equal(byAgent.agent2.taskSetBy, null);
|
||||
assert.deepEqual(body.registrationErrors, []);
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
});
|
||||
|
||||
test("page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it", () => {
|
||||
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
||||
const lines = html.split("\n");
|
||||
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
|
||||
assert.ok(rowPair);
|
||||
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
|
||||
assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
|
||||
const tagLine = lines.find((l) => l.includes("function setByTag(rec)"));
|
||||
assert.ok(tagLine);
|
||||
assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
|
||||
assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
|
||||
assert.match(tagLine, /not verified/);
|
||||
const textLine = lines.find((l) => l.includes("function setByText(rec)"));
|
||||
assert.ok(textLine);
|
||||
assert.match(textLine, /esc\(rec\.taskSetBy\)/);
|
||||
assert.match(textLine, /not verified/);
|
||||
// Attribution never enables a reply: canReply() reads the registration only.
|
||||
const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
|
||||
assert.ok(canReplyFn);
|
||||
assert.equal(canReplyFn[0].includes("taskSetBy"), false);
|
||||
assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
|
||||
});
|
||||
|
||||
+27
-3
@@ -3,7 +3,7 @@
|
||||
`mosaic launch <seat>` starts a seat through its existing launch script,
|
||||
unchanged, and leaves one registration record that the control board reads
|
||||
instead of guessing. `mosaic seat task <seat> <text>` changes the task on
|
||||
that record. Nothing else. No provider or auth registry, no roster schema
|
||||
that record and notes who set it. Nothing else. No provider or auth registry, no roster schema
|
||||
change, no stopping or killing of seats, one record per seat.
|
||||
|
||||
Issue #1504. Plain ESM, no dependencies, Node 24 or newer.
|
||||
@@ -17,7 +17,7 @@ or `seat` command; run it by path.
|
||||
```
|
||||
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||||
```
|
||||
|
||||
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
|
||||
@@ -36,6 +36,17 @@ scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config P
|
||||
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. A
|
||||
launch script that sees this variable is already registered and must not
|
||||
call `mosaic launch` again; `mosaic launch` refuses to run when it is set.
|
||||
- `seat task` records who set the task in `taskSetBy` (#1511): `--by NAME`
|
||||
if given, else `$MOSAIC_AGENT_NAME` if set and non-empty, else the word
|
||||
`unknown`. An explicit value must be one token of 1 to 64 characters
|
||||
(letters, digits, `.` `_` `@` `:` `-`, starting with a letter or digit);
|
||||
anything else refuses with exit 4 before the record is touched. A set but
|
||||
malformed `MOSAIC_AGENT_NAME` also refuses rather than being reported as
|
||||
`unknown`; `--by` is the explicit way past it. The value is never echoed
|
||||
back in a refusal. **This is what the caller said, not a verified
|
||||
identity.** Nothing checks it, nothing grants on it; the board displays
|
||||
it next to the task and does no more with it. `launch --task` does not
|
||||
set the field.
|
||||
|
||||
The four repository seats (`agents/darkwing`, `agents/dewey`,
|
||||
`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh`
|
||||
@@ -81,6 +92,10 @@ scan never changes it.
|
||||
}
|
||||
```
|
||||
|
||||
After `scripts/mosaic seat task darkwing "Row 6 (#1511)" --by jason` the
|
||||
same record also carries `"taskSetBy": "jason"` and a non-null
|
||||
`updatedAt`; `startedAt` and every other field are unchanged.
|
||||
|
||||
Fields asked for in the brief: `seat`, `project`, `task` (empty unless
|
||||
`--task` or a later `seat task`), `workspace`, `tmux` (session name and
|
||||
socket, from the `TMUX` variable of the pane the launch ran in; null outside
|
||||
@@ -100,6 +115,11 @@ Fields added, and why:
|
||||
the record is still written but the board cannot match it).
|
||||
- `updatedAt`: set only by `seat task`, so a task change is distinguishable
|
||||
from a relaunch.
|
||||
- `taskSetBy`: set only by `seat task` (see above). Optional in the shape:
|
||||
a record written before the field existed loads unchanged, the board shows
|
||||
`unknown` for it, and nothing rewrites it until the next `seat task` or
|
||||
launch. The record version stays 1; a value that is not one bounded token
|
||||
makes the record unreadable (refused, not misread).
|
||||
- `version`: so a later shape change can be refused rather than misread.
|
||||
|
||||
`project` and `workspace` are derived only for the repo layout (the
|
||||
@@ -119,7 +139,11 @@ registration are unchanged. A malformed record is reported in
|
||||
`registrationErrors` on the index and skipped; it never takes the board down
|
||||
and it is never treated as absent silently. A record whose pid is no
|
||||
longer running is stale: still shown on the Registered line, but it does
|
||||
not override anything.
|
||||
not override anything. The row's `taskSetBy` is the record's setter only
|
||||
while the task shown is the registered one (`taskSource` `registration`);
|
||||
otherwise it is null, so a stale or empty registration never attributes a
|
||||
transcript-derived task, and a Discord connector's fixed task never borrows
|
||||
a native record's setter.
|
||||
|
||||
The repository launch scripts are the only launchers that register.
|
||||
Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
// Usage:
|
||||
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
// mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||||
//
|
||||
// `seat task` records who set the task as taskSetBy: `--by NAME`, else
|
||||
// $MOSAIC_AGENT_NAME, else "unknown". This is what the caller claimed; it is
|
||||
// not verified and it authorizes nothing.
|
||||
//
|
||||
// `launch` writes <dataRoot>/seats/<layout>/<seat>/registration.json, then replaces
|
||||
// itself with the seat's launch.sh, unchanged, with everything after `--` as
|
||||
@@ -15,12 +19,12 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import {
|
||||
SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext,
|
||||
makeRegistration, writeRegistration, updateTask,
|
||||
makeRegistration, writeRegistration, updateTask, resolveSetBy,
|
||||
} from "./seat.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
|
||||
" mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]",
|
||||
" mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
|
||||
].join("\n");
|
||||
|
||||
export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED";
|
||||
@@ -49,10 +53,10 @@ function parseLaunch(argv) {
|
||||
}
|
||||
|
||||
function parseSeatTask(argv) {
|
||||
const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() };
|
||||
const opts = { seat: null, task: null, layout: null, by: null, config: defaultConfigPath() };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--config" || a === "--layout") {
|
||||
if (a === "--config" || a === "--layout" || a === "--by") {
|
||||
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
|
||||
opts[a.slice(2)] = argv[++i];
|
||||
} else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
|
||||
@@ -90,9 +94,12 @@ function launch(argv) {
|
||||
|
||||
function seatTask(argv) {
|
||||
const opts = parseSeatTask(argv);
|
||||
// Attribution is resolved (and an invalid value refused) before any read
|
||||
// or write, so a refusal leaves the record exactly as it was.
|
||||
const setBy = resolveSetBy({ by: opts.by, env: process.env });
|
||||
const seats = seatsDir(loadDataRoot(opts.config));
|
||||
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout });
|
||||
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`);
|
||||
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout, setBy });
|
||||
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters, set by ${setBy})\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ import { spawnSync } from "node:child_process";
|
||||
export const REGISTRATION_VERSION = 1;
|
||||
export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
export const TASK_LIMIT = 2000;
|
||||
// Who set the task (#1511): caller-supplied attribution, bounded to one
|
||||
// short token so it cannot carry markup or whitespace. The bound is a syntax
|
||||
// limit, not a privacy filter: a numeric ID or an email-like name still fits,
|
||||
// so callers choose what they put here. It is what the caller claimed, not
|
||||
// an authenticated identity, and it grants nothing: the board displays it
|
||||
// and does no more with it.
|
||||
export const SET_BY_NAME = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
|
||||
export const SET_BY_UNKNOWN = "unknown";
|
||||
export const SET_BY_ENV = "MOSAIC_AGENT_NAME";
|
||||
|
||||
// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or
|
||||
// configuration, 4 usage.
|
||||
@@ -121,6 +130,7 @@ export function tmuxContext({ env = process.env, exec = spawnSync } = {}) {
|
||||
const FIELDS = Object.freeze([
|
||||
"version", "seat", "project", "task", "workspace", "tmux", "harness",
|
||||
"startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt",
|
||||
"taskSetBy",
|
||||
]);
|
||||
|
||||
const isNullableString = (v) => v === null || typeof v === "string";
|
||||
@@ -134,6 +144,9 @@ export function validateRegistration(record) {
|
||||
if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version");
|
||||
if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid");
|
||||
if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string");
|
||||
// Optional: records written before #1511 have no taskSetBy and still load
|
||||
// (the board shows "unknown"); no version change, no migration on read.
|
||||
if (record.taskSetBy !== undefined && !(typeof record.taskSetBy === "string" && SET_BY_NAME.test(record.taskSetBy))) throw new SeatError("registration.taskSetBy is invalid");
|
||||
for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) {
|
||||
if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`);
|
||||
}
|
||||
@@ -208,17 +221,37 @@ export function findRegistrations(seats, seat) {
|
||||
return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean);
|
||||
}
|
||||
|
||||
// Change the task field only. Refuses when the seat was never launched
|
||||
// through `mosaic launch`, because there is nothing to attach the task to.
|
||||
// A name that exists in more than one layout must be qualified with layout.
|
||||
export function updateTask(seats, seat, task, { layout = null, now = () => new Date() } = {}) {
|
||||
// Who a `seat task` is attributed to: an explicit `--by NAME`, else the
|
||||
// MOSAIC_AGENT_NAME variable, else "unknown". An explicit value must match
|
||||
// SET_BY_NAME; so must a non-empty environment value, because a malformed
|
||||
// or unexpected value there is not the same as no value and is refused
|
||||
// rather than silently reported as "unknown". Neither value is echoed back.
|
||||
// The result is a claim by the caller, nothing more.
|
||||
export function resolveSetBy({ by = null, env = process.env } = {}) {
|
||||
if (by !== null && by !== undefined) {
|
||||
if (typeof by !== "string" || !SET_BY_NAME.test(by)) throw new SeatError(`--by must be 1-64 characters of letters, digits, . _ @ : or -, starting with a letter or digit`, 4);
|
||||
return by;
|
||||
}
|
||||
const fromEnv = env[SET_BY_ENV];
|
||||
if (fromEnv === undefined || fromEnv === "") return SET_BY_UNKNOWN;
|
||||
if (typeof fromEnv !== "string" || !SET_BY_NAME.test(fromEnv)) throw new SeatError(`${SET_BY_ENV} is set but is not a valid name (1-64 characters of letters, digits, . _ @ : or -); pass --by NAME or unset it`, 4);
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
// Change the task field, and record who set it (taskSetBy), and updatedAt.
|
||||
// Every other field, startedAt included, is carried over unchanged. Refuses
|
||||
// when the seat was never launched through `mosaic launch`, because there is
|
||||
// nothing to attach the task to. A name that exists in more than one layout
|
||||
// must be qualified with layout.
|
||||
export function updateTask(seats, seat, task, { layout = null, now = () => new Date(), setBy = SET_BY_UNKNOWN } = {}) {
|
||||
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
|
||||
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
|
||||
if (typeof setBy !== "string" || !SET_BY_NAME.test(setBy)) throw new SeatError("setBy must be a valid name", 4);
|
||||
const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat);
|
||||
if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1);
|
||||
if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4);
|
||||
const record = found[0];
|
||||
const updated = { ...record, task, updatedAt: now().toISOString() };
|
||||
const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() };
|
||||
writeRegistration(seats, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
updateTask,
|
||||
findRegistrations,
|
||||
samePath,
|
||||
resolveSetBy,
|
||||
TASK_LIMIT,
|
||||
SET_BY_NAME,
|
||||
SET_BY_UNKNOWN,
|
||||
} from "../src/seat.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
@@ -91,6 +94,9 @@ function buildConfig(root, dataRoot = join(root, "data")) {
|
||||
function cliEnv(overrides = {}) {
|
||||
const env = { ...process.env, TMUX: "", TMUX_PANE: "" };
|
||||
delete env.MOSAIC_LAUNCH_REGISTERED;
|
||||
// The developer's shell may carry a seat name (agents/rocko/launch.sh
|
||||
// exports one); attribution tests set it explicitly or not at all.
|
||||
delete env.MOSAIC_AGENT_NAME;
|
||||
return { ...env, ...overrides };
|
||||
}
|
||||
|
||||
@@ -290,7 +296,7 @@ test("writeRegistration/readRegistration: round trip, permissions, absence, and
|
||||
// 7. updateTask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("updateTask: changes task and updatedAt only, and refuses appropriately", () => {
|
||||
test("updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately", () => {
|
||||
const root = makeRoot();
|
||||
const seatDir = buildRepoSeat(root, "myseat");
|
||||
const resolved = resolveSeat("myseat", { repo: root });
|
||||
@@ -300,11 +306,14 @@ test("updateTask: changes task and updatedAt only, and refuses appropriately", (
|
||||
|
||||
const updated = updateTask(seats, "myseat", "revised");
|
||||
assert.equal(updated.task, "revised");
|
||||
assert.equal(updated.taskSetBy, SET_BY_UNKNOWN, "no setBy option records the literal unknown");
|
||||
assert.notEqual(updated.updatedAt, null);
|
||||
for (const key of Object.keys(record)) {
|
||||
if (key === "task" || key === "updatedAt") continue;
|
||||
assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
assert.equal(updated.startedAt, record.startedAt, "startedAt is the launch time and survives a task update");
|
||||
assert.deepEqual(Object.keys(updated).sort(), [...Object.keys(record), "taskSetBy"].sort(), "exactly one field is added");
|
||||
|
||||
// Refuses when there is no registration to attach to.
|
||||
assert.throws(() => updateTask(seats, "ghost", "x"), (err) => {
|
||||
@@ -560,3 +569,165 @@ test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", ()
|
||||
assert.equal(samePath(1, "x"), false);
|
||||
assert.equal(samePath(null, target), false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. Task attribution (#1511): taskSetBy on the record, resolveSetBy, and the
|
||||
// --by / MOSAIC_AGENT_NAME precedence at the CLI. Attribution is a claim by
|
||||
// the caller: bounded, escaped by the presentations, never authority.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4", () => {
|
||||
assert.equal(resolveSetBy({ by: "darkwing", env: { MOSAIC_AGENT_NAME: "rocko" } }), "darkwing");
|
||||
assert.equal(resolveSetBy({ by: null, env: { MOSAIC_AGENT_NAME: "rocko" } }), "rocko");
|
||||
assert.equal(resolveSetBy({ env: {} }), SET_BY_UNKNOWN);
|
||||
assert.equal(resolveSetBy({ env: { MOSAIC_AGENT_NAME: "" } }), SET_BY_UNKNOWN);
|
||||
assert.equal(resolveSetBy({ by: "jason@host:1", env: {} }), "jason@host:1");
|
||||
assert.equal(resolveSetBy({ by: "a".repeat(64), env: {} }), "a".repeat(64), "64 characters is the bound");
|
||||
const refused = (fn, pattern) => assert.throws(fn, (err) => {
|
||||
assert.ok(err instanceof SeatError);
|
||||
assert.equal(err.exitCode, 4);
|
||||
assert.match(err.message, pattern);
|
||||
return true;
|
||||
});
|
||||
for (const bad of ["", " ", "a".repeat(65), "<b>x</b>", "two words", "-leading", ".leading", "tab\tname", "new\nline", "ünïcode", "a/b", "a;b", "$(x)"]) {
|
||||
refused(() => resolveSetBy({ by: bad, env: { MOSAIC_AGENT_NAME: "valid" } }), /--by/);
|
||||
assert.equal(SET_BY_NAME.test(bad), false);
|
||||
// The offending value is never echoed back.
|
||||
if (bad.trim().length > 1) assert.throws(() => resolveSetBy({ by: bad, env: {} }), (err) => !err.message.includes(bad));
|
||||
}
|
||||
refused(() => resolveSetBy({ by: 42, env: {} }), /--by/);
|
||||
// A set but malformed environment value is refused, not read as unknown:
|
||||
// silently reporting "unknown" would hide a misconfigured seat, and --by
|
||||
// is the explicit way past it.
|
||||
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), /MOSAIC_AGENT_NAME/);
|
||||
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "a".repeat(65) } }), /MOSAIC_AGENT_NAME/);
|
||||
assert.throws(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), (err) => !err.message.includes("<script>"));
|
||||
assert.equal(resolveSetBy({ by: "explicit", env: { MOSAIC_AGENT_NAME: "<script>" } }), "explicit", "an explicit value does not consult the environment at all");
|
||||
});
|
||||
|
||||
test("validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change", () => {
|
||||
const root = makeRoot();
|
||||
const seatDir = buildRepoSeat(root, "old");
|
||||
const resolved = resolveSeat("old", { repo: root });
|
||||
const seats = seatsDir(join(root, "data"));
|
||||
const record = makeRegistration({ resolved, task: "launched task", pid: 1 });
|
||||
assert.equal("taskSetBy" in record, false, "launch does not attribute; only seat task does");
|
||||
assert.equal(record.version, 1);
|
||||
// An old on-disk record, byte for byte, without the field.
|
||||
const path = registrationPath(seats, "old", "repo");
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const oldBytes = JSON.stringify(record, null, 2) + "\n";
|
||||
writeFileSync(path, oldBytes);
|
||||
const loaded = readRegistration(seats, "old", "repo");
|
||||
assert.deepEqual(loaded, record);
|
||||
assert.equal(loaded.taskSetBy, undefined);
|
||||
assert.equal(readFileSync(path, "utf8"), oldBytes, "reading never rewrites (no implicit migration)");
|
||||
assert.equal(findRegistrations(seats, "old").length, 1);
|
||||
// Valid values.
|
||||
for (const ok of ["unknown", "darkwing", "jason@host:1", "a".repeat(64)]) validateRegistration({ ...record, taskSetBy: ok });
|
||||
// Invalid values, never echoed.
|
||||
for (const bad of [null, 7, "", "<b>", "two words", "a".repeat(65), {}]) {
|
||||
assert.throws(() => validateRegistration({ ...record, taskSetBy: bad }), (err) => {
|
||||
assert.ok(err instanceof SeatError);
|
||||
assert.match(err.message, /taskSetBy/);
|
||||
if (typeof bad === "string" && bad.length > 1) assert.equal(err.message.includes(bad), false);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
writeFileSync(path, JSON.stringify({ ...record, taskSetBy: "<b>" }));
|
||||
assert.throws(() => readRegistration(seats, "old", "repo"), /taskSetBy/);
|
||||
});
|
||||
|
||||
test("updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution", () => {
|
||||
const root = makeRoot();
|
||||
buildRepoSeat(root, "myseat");
|
||||
const resolved = resolveSeat("myseat", { repo: root });
|
||||
const seats = seatsDir(join(root, "data"));
|
||||
const started = new Date("2026-09-10T10:00:00Z");
|
||||
const record = makeRegistration({ resolved, task: "original", harness: "pi", tmux: { socket: "mosaic-fleet", session: "myseat" }, pid: 1, workspace: "/w", project: "proj", now: () => started });
|
||||
writeRegistration(seats, record);
|
||||
|
||||
const first = updateTask(seats, "myseat", "by darkwing", { setBy: "darkwing", now: () => new Date("2026-09-14T10:00:00Z") });
|
||||
assert.equal(first.taskSetBy, "darkwing");
|
||||
assert.equal(first.startedAt, started.toISOString());
|
||||
assert.equal(first.updatedAt, "2026-09-14T10:00:00.000Z");
|
||||
for (const key of ["seat", "project", "workspace", "tmux", "harness", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "version"]) {
|
||||
assert.deepEqual(first[key], record[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
assert.deepEqual(readRegistration(seats, "myseat", "repo"), first);
|
||||
|
||||
const second = updateTask(seats, "myseat", "by jason", { setBy: "jason", now: () => new Date("2026-09-14T11:00:00Z") });
|
||||
assert.equal(second.taskSetBy, "jason", "a later set replaces the attribution; it is not appended");
|
||||
assert.equal(second.startedAt, started.toISOString());
|
||||
|
||||
const third = updateTask(seats, "myseat", "anonymous");
|
||||
assert.equal(third.taskSetBy, SET_BY_UNKNOWN, "no setBy means unknown, never the previous name");
|
||||
|
||||
// Invalid setBy refuses with exit 4 before anything is written.
|
||||
const before = readFileSync(registrationPath(seats, "myseat", "repo"), "utf8");
|
||||
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "<b>" }), (err) => err instanceof SeatError && err.exitCode === 4);
|
||||
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "" }), (err) => err instanceof SeatError && err.exitCode === 4);
|
||||
assert.equal(readFileSync(registrationPath(seats, "myseat", "repo"), "utf8"), before);
|
||||
});
|
||||
|
||||
test("CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte", () => {
|
||||
const root = makeRoot();
|
||||
buildRepoSeat(root, "myseat");
|
||||
const { configPath, dataRoot } = buildConfig(root);
|
||||
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
|
||||
const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, MOSAIC_AGENT_NAME: "launcher" }) });
|
||||
assert.equal(launchResult.status, 0, launchResult.stderr);
|
||||
const launched = JSON.parse(readFileSync(path, "utf8"));
|
||||
assert.equal("taskSetBy" in launched, false, "launch --task does not attribute, even with the variable set");
|
||||
|
||||
const run = (args, env) => runCli(["seat", "task", "myseat", ...args, "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, ...env }) });
|
||||
const read = () => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
let r = run(["one", "--by", "darkwing"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by darkwing/);
|
||||
assert.equal(read().taskSetBy, "darkwing");
|
||||
assert.equal(read().task, "one");
|
||||
assert.equal(read().startedAt, launched.startedAt);
|
||||
|
||||
r = run(["two"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by rocko/);
|
||||
assert.equal(read().taskSetBy, "rocko");
|
||||
|
||||
r = run(["three"], {});
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by unknown/);
|
||||
assert.equal(read().taskSetBy, "unknown");
|
||||
|
||||
r = run(["four"], { MOSAIC_AGENT_NAME: "" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(read().taskSetBy, "unknown");
|
||||
for (const key of Object.keys(launched)) {
|
||||
if (key === "task" || key === "updatedAt") continue;
|
||||
assert.deepEqual(read()[key], launched[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
|
||||
const frozen = readFileSync(path, "utf8");
|
||||
r = run(["five", "--by", "<script>alert(1)</script>"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /refused: --by/);
|
||||
assert.equal(r.stderr.includes("<script>"), false, "the refusal does not echo the value");
|
||||
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid --by writes nothing");
|
||||
r = run(["five", "--by", ""], {});
|
||||
assert.equal(r.status, 4);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen);
|
||||
r = runCli(["seat", "task", "myseat", "five", "--config", configPath, "--by"], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }) });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /missing value for --by/);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen);
|
||||
r = run(["five"], { MOSAIC_AGENT_NAME: "not a name" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /MOSAIC_AGENT_NAME/);
|
||||
assert.equal(r.stderr.includes("not a name"), false);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid environment value writes nothing");
|
||||
r = run(["six", "--by", "explicit"], { MOSAIC_AGENT_NAME: "not a name" });
|
||||
assert.equal(r.status, 0, "an explicit --by is the way past a bad environment value");
|
||||
assert.equal(read().taskSetBy, "explicit");
|
||||
assert.equal(read().task, "six");
|
||||
});
|
||||
|
||||
@@ -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