Files
stack/agents/dewey/work/queue-54/row54-r1-to-r2.patch
T

438 lines
35 KiB
Diff

--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -157,7 +157,8 @@
with a Copy button and has no button that moves a row. The brief is shown
as its path and anchor, because Console serves no repository files.
- `#/business` shows the business named by `--business`, else the running
- bus host's business: the human, arbiters, projects, launch rules, the vars
+ bus host's business: the human, the arbiters as instance and kind ("pm
+ (delivery), cto (technical)"), projects, launch rules, the vars
on an allowlist, each role instance with its holder and harness, the
authority of each instance for each action as the business package
classifies it (`resolveInstance`, `classify`), and credentials as
@@ -173,9 +174,14 @@
response, page or log carries one, a file path or a Discord channel, guild or
user id. The server redacts every message and string field before it answers
(`redactor` in `src/reads.mjs`): the config directory becomes `<config>`, the
-data root `<dataRoot>`, any other absolute path `<path>`, a 17 to 20 digit run
-`<id>`, and a JSON parse error's quote of the file is dropped. The startup
-log goes through the same redactor.
+data root `<dataRoot>`, any other absolute or `~/` path `<path>`, a 17 to 20
+digit run `<id>`, and a JSON parse error's quote of the file is dropped. The
+startup log goes through the same redactor. A path starts at the beginning
+of the text or after a space, an opening bracket, a quote, `=`, `,`, `<` or
+`:`, so `file:/x` and `file:///x` are redacted and `https://host/x` is not.
+Known gap: a path stops at the first space, so a path containing a space is
+redacted only up to that space and the rest shows. A bare `~` or `~user/`
+is not treated as a path, and neither is a relative path.
## Data and boundaries
@@ -211,7 +217,12 @@
400. The queue is read in a child process (`src/queue-read.mjs`) through
`rows()` from `packages/queue/src/store.mjs` (lead decision 83): the same
read as `queue list`, with no write and no network. Like `list`, it takes
-the queue lock only to recheck a disagreement before reporting it. The business
+the queue lock only to recheck a disagreement before reporting it. The child
+imports the store inside its `try`, so a store that fails to load reports
+"the queue read failed". Only the store's exit 2 (invalid data or refused)
+forwards the child's text, as `queue-refused`; any other exit is the fixed
+`read-failed` message "the queue read failed (exit N)", because that stderr
+may be Node's own, with a `file://` path and a stack. The business
read uses `packages/business` in process. No read writes, calls out or adds a
dependency. Answers carry `at`; failures are `{ error, message }` with 503
for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404
@@ -282,16 +293,26 @@
palette's own reads and from the board page, and that palette labels are text.
Row 54's tests share the seeded fixture in `reads-fixture.mjs`: a scratch
-queue whose row note names a token path and an id, and a business, notifier
-config and Discord binding that hold token files, an environment variable
-name, and guild, channel, bot and user ids. `reads.test.mjs` checks each
+queue whose row note names an id and token paths shaped like real notes
+(absolute, `~/` and after `file:`; the store refuses `<` in a note, so the
+`<…>` case is in the redactor test), and a business,
+notifier config and Discord binding that hold token files, an environment
+variable name, an agent `model` var holding a path, and guild, channel, bot
+and user ids. Row 9's After entry has the `{id, when}` shape the real queue
+stores. `reads.test.mjs` checks each
route's body and status, the redactor, the credential state, methods and
-Origin, and that no body carries any seeded value or the temporary directory.
+Origin, and that no body carries any seeded value or the temporary directory;
+that a `store.mjs` with a syntax error, or none, gives the fixed message with
+no path; and that the child's timeout, output cap and non-2 exits hold, over a
+fake child script.
`views.test.mjs` walks Queue, a queue row, Business and Settings through the
five states over a stub read, with each refusal code the view can meet, at
400px; checks that a refusal leaves no queue row in the palette, that no view
has a control but Copy and Read again, and that the row page shows the
-`queue move` command; then, over the seeded fixture, that neither the page nor
+`queue move` command, that the 10 s refresh keeps focus on a Settings mirror
+select, and that the palette keeps its queue rows after its own inbox and tasks
+reads answer; then, over the seeded fixture, that the Arbiters line reads the
+business file's object, that neither the page nor
any response it read carries a seeded value, that a missing or invalid
business file shows the module's text, and that the startup log names no
config path.
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -345,7 +345,11 @@
const QSTATE = { done: 'ok', blocked: 'danger', 'waiting-on-jason': 'attn', 'in-review': 'attn', 'in-progress': 'attn' };
const qState = s => badge(s, QSTATE[s] || 'muted');
const qLink = id => Number.isInteger(id) ? `<a href="#/queue/${esc(id)}">${esc(id)}</a>` : txt(id);
+ // An after entry is {id, when}, as QUEUE.md writes it: "53 done".
+ const afterRef = a => a && typeof a === 'object' ? `${qLink(a.id)}${a.when ? ` ${txt(a.when)}` : ''}` : qLink(a);
const names = list => Array.isArray(list) && list.length ? list.map(txt).join(', ') : none;
+ // The business file's arbiters are {delivery, technical}, each a role instance: "pm (delivery)".
+ const arbiterRefs = a => a && typeof a === 'object' && !Array.isArray(a) ? Object.entries(a).map(([kind, who]) => `${txt(who)} (${txt(kind)})`).join(', ') || none : names(a);
const issueRefs = list => Array.isArray(list) && list.length ? list.map(n => `#${txt(n)}`).join(', ') : none;
// Console serves no repository files, so the brief is its path and anchor, as the row names it.
const briefRef = b => b?.path ? `<code>${txt(b.path)}${b.anchor ? `#${txt(b.anchor)}` : ''}</code>` : none;
@@ -377,7 +381,7 @@
const cmd = `scripts/mosaic queue move ${id} <state> --op <op> --by <seat>`;
return `${head(`Row ${esc(id)}: ${txt(r.piece)}`, `${qState(r.state)}${r.previousState ? ` <span class="small muted">was ${txt(r.previousState)}</span>` : ''} ${readLine()}`)}
${storeNotes(doc.notes)}
- <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(qLink).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>${txt(r.required)}${r.requiredSince ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
+ <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(afterRef).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>yes${Number.isFinite(Date.parse(r.requiredSince)) ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
<section aria-labelledby="q-review"><h2 id="q-review">Review <span class="count">${rounds.length}</span></h2>${rounds.length ? `<ol class="s1-plain">${rounds.map(round).join('')}</ol>` : '<p class="muted">No review round yet.</p>'}</section>
<section aria-labelledby="q-move"><h2 id="q-move">Moving this row</h2><p class="small muted">${moveHelp} Copy only puts the command on your clipboard; fill in the state, op and seat before you run it.</p>
<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the move command for row ${esc(id)}">Copy</button></div><p class="small muted" id="copy-status"></p>
@@ -401,7 +405,7 @@
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
- <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${names(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
+ <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
@@ -425,7 +429,7 @@
}
// Trail
- const GROUPS =[['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
+ const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
function trailList(rows, filter) {
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
--- a/packages/webui/src/queue-read.mjs
+++ b/packages/webui/src/queue-read.mjs
@@ -3,14 +3,19 @@
// calls rows() in packages/queue (lead decision 83): the same unlocked read
// as `queue list`, which writes nothing. JSON on stdout; a refusal's message
// on stderr with the store's exit code.
+//
+// The store is imported inside the try, so a store.mjs that fails to load
+// (a half-written file in a shared checkout) prints "the queue read failed",
+// never Node's own message, which names the file:// path and a stack.
-import { rows } from '../../queue/src/store.mjs';
-import { QueueError } from '../../queue/src/errors.mjs';
-
+let QueueError = null;
try {
+ ({ QueueError } = await import('../../queue/src/errors.mjs'));
+ const { rows } = await import('../../queue/src/store.mjs');
const r = rows({});
process.stdout.write(`${JSON.stringify({ rows: r.rows, notes: r.err })}\n`);
} catch (err) {
- process.stderr.write(`${err instanceof QueueError ? err.message : 'the queue read failed'}\n`);
- process.exitCode = err instanceof QueueError && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
+ const refused = QueueError !== null && err instanceof QueueError;
+ process.stderr.write(`${refused ? err.message : 'the queue read failed'}\n`);
+ process.exitCode = refused && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
}
--- a/packages/webui/src/reads.mjs
+++ b/packages/webui/src/reads.mjs
@@ -10,7 +10,11 @@
//
// Every string a response carries passes through the redactor: the config
// directory and dataRoot become <config> and <dataRoot>, any other absolute
-// path <path>, and a 17 to 20 digit run (a Discord id) <id>.
+// or ~/ path <path>, and a 17 to 20 digit run (a Discord id) <id>. A path
+// starts at the beginning, a space, an opening bracket, a quote, `=`, `,`,
+// `<` or `:`, so `file:/x` and `file:///x` are paths and `https://host/x`
+// is not. A path stops at a space, so one with a space in it is redacted
+// only up to the space.
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
@@ -41,7 +45,7 @@
return text => {
let s = unquote(String(text));
for (const [re, label] of known) s = s.replace(re, label);
- return s.replace(/(^|[\s('"`=,[{])\/[^\s'"`),;\]}]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
+ return s.replace(/(^|[\s('"`=,[{<]|:(?!\/\/[^/]))~?\/[^\s'"`),;\]}>]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
};
}
// Every string in a value, deep. Keys are left alone: they are fixed names or ids.
@@ -75,10 +79,12 @@
child.on('close', status => {
const said = Buffer.concat(err).toString('utf8').trim().slice(0, 2000);
if (status === null) return done(reject, new ReadError('read-failed', 'the queue read did not finish'));
- // 2 is the store's "invalid data or refused": fail closed. Anything
- // else failed to run; the view keeps its last read.
+ // 2 is the store's "invalid data or refused": fail closed with the
+ // store's text. Anything else failed to run, and its stderr may be
+ // Node's own (a path and a stack), so it is not shown; the view keeps
+ // its last read.
if (status === 2) return done(reject, new ReadError('queue-refused', said || 'the queue refused the read'));
- if (status !== 0) return done(reject, new ReadError('read-failed', said || `the queue read exited ${status}`));
+ if (status !== 0) return done(reject, new ReadError('read-failed', `the queue read failed (exit ${status})`));
try {
const doc = JSON.parse(Buffer.concat(out).toString('utf8'));
if (!Array.isArray(doc?.rows) || !Array.isArray(doc?.notes)) throw new Error('shape');
@@ -193,6 +199,7 @@
let id = null, notifier;
try {
id = businessId({ business, dataRoot });
+ if (!dataRoot) throw new ReadError('not-configured', 'the Console has no system config, so it knows no notifier config');
notifier = { binding: readNotifyConfig(dataRoot, id) };
} catch (err) {
notifier = { refused: err instanceof ReadError ? err.code : 'not-configured', message: err.message };
--- a/packages/webui/tests/reads-fixture.mjs
+++ b/packages/webui/tests/reads-fixture.mjs
@@ -1,9 +1,11 @@
// Row 54 (#1543): the seeded fixture the reads and views tests share. A
// business holds a Discord user id, token files, an environment variable
// name and bot ids; a data root holds a notifier config and a Discord
-// binding with guild, channel and user ids and a token file. A queue row's
-// note names a token path and an id. None may reach a response, the page or
-// a log, nor may the temporary directory.
+// binding with guild, channel and user ids and a token file; an agent's
+// model var holds a path. A queue row's note names an id and token paths in
+// the shapes real notes use (row 35: "~/.config/mosaic-dev/secrets/…").
+// None may reach a response, the page or a log, nor may the temporary
+// directory. Rows 9 and 11 have After entries in the stored {id, when} shape.
import assert from 'node:assert/strict';
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -15,10 +17,13 @@
export const SNOWFLAKES = ['123456789012345678', '223456789012345678', '323456789012345678', '423456789012345678', '523456789012345678'];
export const day = n => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
+export const NOTE = `token at /srv/secret/x.token for ${SNOWFLAKES[4]}; tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (receipt file:/srv/secret/r.json)`;
+export const NOTE_SHOWN = 'token at <path> for <id>; tokens 0600 under <path> (receipt file:<path>)';
+
// A canonical scratch checkout with a committed queue, the reader script
// beside a copy of packages/queue/src, and a RELEASE file.
export function queueRepo(t) {
- const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: `token at /srv/secret/x.token for ${SNOWFLAKES[4]}` } : r);
+ const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: NOTE } : r.id === 11 ? { ...r, after: [{ id: 9, when: 'done' }] } : r);
const repo = scratchRepo(t, { map: mapText(rows) });
genesisCommitted(repo);
mkdirSync(join(repo.root, 'packages/webui/src'), { recursive: true });
@@ -37,6 +42,7 @@
doc.roles.coder.credentials.gitea = { env: 'CODER_GITEA_SECRET_ENV', rotateBy: day(-1) };
doc.roles.coder.credentials.vikunja.expires = day(3);
doc.roles.reviewer.credentials.vikunja.expires = day(-2);
+ doc.roles.reviewer.vars = { model: '/srv/secret/models/local.gguf' };
if (business) writeJson(join(configDir, 'businesses', 'acme.json'), doc);
if (notify) writeJson(join(dataRoot, 'notify', 'acme', 'notify.json'), { notifyVersion: 1, binding: 'jason-dm' });
writeJson(join(dataRoot, 'discord', 'jason-dm.json'), {
@@ -51,5 +57,5 @@
export function clean(text, ...bases) {
for (const b of bases) assert.equal(text.includes(b), false, `response names ${b}`);
for (const s of SNOWFLAKES) assert.equal(text.includes(s), false, `response carries id ${s}`);
- for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret']) assert.equal(text.includes(s), false, `response carries ${s}`);
+ for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret', '~/', 'secrets/mosaic-stack']) assert.equal(text.includes(s), false, `response carries ${s}`);
}
--- a/packages/webui/tests/reads.test.mjs
+++ b/packages/webui/tests/reads.test.mjs
@@ -3,11 +3,13 @@
// the temporary directory.
import { test } from 'node:test';
import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
-import { consoleReads, credentialState, redactor } from '../src/reads.mjs';
+import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
-import { SRC, SNOWFLAKES, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
+import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
async function serve(t, reads) {
const server = await startServer({ port: 0, reads });
@@ -23,6 +25,12 @@
assert.equal(r('file /run/x.token, see https://git.example/a'), 'file <path>, see https://git.example/a');
assert.equal(r('/home/u/.config/mosaic-devX/y'), '<path>');
assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user <id> ok');
+ // ~/ paths, and a path after `:` or `<` (Filbert R1 and Sage, #1543 comment 27185). A URL keeps its path.
+ assert.equal(r('tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (lead decision 74)'), 'tokens 0600 under <path> (lead decision 74)');
+ assert.equal(r('no `~/.mosaic` changes; key=~/k'), 'no `<path>` changes; key=<path>');
+ assert.equal(r('file:/x, file:///srv/y and </srv/z.token>'), 'file:<path>, file:<path> and <<path>>');
+ assert.equal(r('https://git.example/a, http://127.0.0.1:3456 and http://h:80/p'), 'https://git.example/a, http://127.0.0.1:3456 and http://h:80/p');
+ assert.equal(r('a~/b, ~ and agents/sage/work/'), 'a~/b, ~ and agents/sage/work/');
const quoted = r('business file is not valid JSON (/home/u/.config/mosaic-dev/businesses/a.json): Unexpected token \'s\', "s3cret-value" is not valid JSON');
assert.equal(quoted.includes('s3cret-value'), false);
assert.match(quoted, /^business file is not valid JSON \(<config>\/businesses\/a\.json\): the parser quoted/);
@@ -46,7 +54,7 @@
assert.deepEqual(body.rows.map(r => r.id), [1, 6, 8, 9, 11]);
const six = body.rows.find(r => r.id === 6);
assert.deepEqual([six.state, six.owner, six.reviewers, six.brief], ['in-progress', 'darkwing', ['filbert'], { path: 'docs/plans/brief-a.md', anchor: 'Row six' }]);
- assert.equal(six.note, 'token at <path> for <id>');
+ assert.equal(six.note, NOTE_SHOWN);
assert.equal(Object.hasOwn(six, 'gate'), false);
assert.ok(Array.isArray(body.notes));
clean(list.text, repo.base);
@@ -55,6 +63,7 @@
const nine = JSON.parse(one.text).row;
assert.deepEqual([nine.id, nine.gate, nine.after], [9, 'filbert approves', [{ id: 6, when: 'settled' }]]);
clean(one.text, repo.base);
+ assert.deepEqual(JSON.parse((await get(base, '/api/queue/11')).text).row.after, [{ id: 9, when: 'done' }]);
assert.equal((await get(base, '/api/queue/7')).status, 404);
for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad);
for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) {
@@ -75,6 +84,27 @@
assert.equal((await get(base, '/api/queue/6')).status, 503);
});
+test('queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack', { timeout: 60000 }, async t => {
+ // Darkwing 27186: a half-written store.mjs used to send Node's own error, file:// path and stack included.
+ for (const [name, breakStore] of [['syntax error', p => writeFileSync(p, 'export const rows = (;\n')], ['missing', p => rmSync(p)]]) {
+ const repo = queueRepo(t);
+ breakStore(join(repo.root, 'packages/queue/src/store.mjs'));
+ const child = spawnSync(process.execPath, [join(repo.root, 'packages/webui/src/queue-read.mjs')], { cwd: repo.root, env: repo.env, encoding: 'utf8' });
+ assert.deepEqual([child.status, child.stdout, child.stderr], [1, '', 'the queue read failed\n'], name);
+ const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES }));
+ const r = await get(base, '/api/queue');
+ assert.equal(r.status, 503, name);
+ assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: 'the queue read failed (exit 1)' }, name);
+ clean(r.text, repo.base, repo.root);
+ }
+ // Only exit 2 forwards the child's text; any other exit is a fixed message.
+ const repo = queueRepo(t), script = join(repo.root, 'packages/webui/src/queue-read.mjs');
+ const fake = (body, opts = {}) => { writeFileSync(script, body); return queueReader({ root: repo.root, env: repo.env, ...opts })(); };
+ await assert.rejects(fake('process.stderr.write("Error: boom\\n at main (file:///srv/q/x.mjs:1:1)\\n"); process.exitCode = 3;\n'), { code: 'read-failed', message: 'the queue read failed (exit 3)' });
+ await assert.rejects(fake('setInterval(() => {}, 1000);\n', { timeoutMs: 300 }), { code: 'read-failed', message: 'the queue read did not finish in time' });
+ await assert.rejects(fake('process.stdout.write("x".repeat(65536));\n', { maxBytes: 1024 }), { code: 'invalid-response', message: 'the queue read printed too much' });
+});
+
test('business: names, vars on the allowlist, authority, credential metadata only', async t => {
const s = seeded(t);
const base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
@@ -86,6 +116,8 @@
assert.deepEqual(b.vars, { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'gitea.baseUrl': 'https://git.example', 'tracker.pollSeconds': 60 });
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]);
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
+ // An allowlisted var is scrubbed too (Filbert T1).
+ assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
for (const i of b.instances) {
assert.deepEqual(Object.keys(i.authority), b.actions);
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
@@ -158,4 +190,9 @@
r = await get(base, '/api/settings');
assert.equal(r.status, 200);
assert.equal(JSON.parse(r.text).notifier.refused, 'not-configured');
+ // --business with no system config: refused in Console's words, not Node's (Filbert N1).
+ base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: null, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
+ r = await get(base, '/api/settings');
+ assert.equal(r.status, 200);
+ assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' });
});
--- a/packages/webui/tests/views.test.mjs
+++ b/packages/webui/tests/views.test.mjs
@@ -15,7 +15,7 @@
import { startServer } from '../src/serve.mjs';
import { consoleReads, ReadError } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
-import { SRC, queueRepo, seeded, clean } from './reads-fixture.mjs';
+import { SRC, NOTE_SHOWN, queueRepo, seeded, clean } from './reads-fixture.mjs';
const PROBE = 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];window.bodies=[];const f=window.fetch;window.fetch=async(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);const r=await f(u,o);bodies.push(await r.clone().text());return r};'
// The bus view's 10s refresh is recorded so a test can fire it early, unless it was cleared.
@@ -62,11 +62,19 @@
test('Queue, Business and Settings in their five states, with no move button', { timeout: 180000 }, async () => {
const s = stubReads();
- const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads });
+ // A bus that answers, so the palette's own inbox and tasks reads succeed and forget nothing.
+ const bus = { inbox: async () => [], tasks: async () => [], agents: async () => [], trail: async () => [] };
+ const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads, bus });
const b = await browser();
try {
const { wait, text, count, flat, refresh, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
+ // Esc closes the palette at once, but its close event, which puts focus back, is a later task.
+ // Waiting for that event keeps a late focus from landing on the next step (seen under load).
+ const closePalette = async () => {
+ await b.evaluate('window.paletteClosed=new Promise(r=>document.querySelector("#cmdk-dialog").addEventListener("close",()=>setTimeout(r),{once:true}));true');
+ await b.key('Escape'); await b.evaluate('window.paletteClosed.then(()=>true)');
+ };
for (const [hash, h1, source, rows, refusals, emptied, emptyText] of [
['#/queue', 'Queue', 'Queue', '#bus-view table.s1-queue tbody tr', ['queue-refused', 'not-configured'], () => { s.docs.queue.rows = []; }, 'The queue has no rows.'],
['#/queue/6', 'Row 6', 'Queue', '#bus-view [aria-labelledby=q-row] dl', ['queue-refused'], null, null],
@@ -114,7 +122,7 @@
assert.equal(await count('#bus-view select[data-mirror]'), hash === '#/settings' ? 2 : 0, hash);
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate(`${palette}.filter(h=>h.startsWith("#/queue/")).length`), 0, `${hash}: no kept queue row in the palette after ${code}`);
- await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
+ await closePalette();
await flat(`${hash} refused`);
s.fail(null); await refresh();
await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
@@ -157,16 +165,23 @@
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-queue tbody tr")].map(e=>e.cells[0].textContent)'), ['7 Second row']);
await go('#/queue/6', 'Row 6');
assert.equal(await text('#bus-view .s1-cmd code'), 'scripts/mosaic queue move 6 <state> --op <op> --by <seat>');
+ await go('#/business', 'Business acme');
+ assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery)');
for (const hash of ['#/queue', '#/queue/6', '#/business', '#/settings']) {
await go(hash, { '#/queue': 'Queue', '#/queue/6': 'Row 6', '#/business': 'Business', '#/settings': 'Settings' }[hash]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view button, #bus-view form, #bus-view input")].filter(e=>!e.matches("button[data-copy], button[data-retry]")).length'), 0, `${hash}: no control but Copy and Read again`);
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view button")].some(e=>/move|resolve|approve|save|edit/i.test(e.textContent))'), false, hash);
}
- // The palette lists queue rows from the last queue read, as text.
- await b.key('k', 'KeyK', 2); await wait(`${palette}.includes("#/queue/7")`);
+ // The palette lists queue rows from the last queue read, as text, and still does once its
+ // inbox and tasks reads have answered. Over a server with no bus they were refusals that
+ // forgot the queue rows, and the check passed only when it ran before they returned.
+ const asked = await b.evaluate('window.requests.length');
+ await b.key('k', 'KeyK', 2);
+ await wait(`window.requests.slice(${asked}).filter(([,u])=>/\\/api\\/bus\\/(inbox|tasks)$/.test(u)).length===2 && window.bodies.length===window.requests.length`);
+ await wait(`${palette}.includes("#/queue/7")`);
assert.equal(await count('#cmdk-list #q-b'), 0);
- await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
+ await closePalette();
// Settings: appearance is the one control, mirrored with the command bar and kept in this browser.
await go('#/settings', 'Settings');
@@ -176,6 +191,11 @@
await b.evaluate('{const m=document.querySelector("#bus-view select[data-mirror=mode]");m.value="dark";m.dispatchEvent(new Event("change",{bubbles:true}))}');
await wait('document.querySelector("#mode").value==="dark"');
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark');
+ // The 10 s refresh redraws Settings and keeps focus on the mirror select (Darkwing D29).
+ await b.evaluate('document.querySelector("#bus-view select[data-mirror=mode]").focus(); document.querySelector("#bus-view h1").dataset.old = "1"');
+ assert.equal(await b.evaluate('fireRefresh()'), 1, 'a refresh is due on #/settings');
+ await wait('!document.querySelector("#bus-view h1").dataset.old && !document.querySelector("#bus-view [aria-busy]")');
+ assert.equal(await b.evaluate('document.activeElement?.matches("#bus-view select[data-mirror=mode]") ?? false'), true, 'the refresh keeps focus on the mirror select');
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
@@ -191,10 +211,21 @@
const seen = async () => clean(await b.evaluate('document.documentElement.outerHTML + "\\n" + window.bodies.join("\\n")'), s.base, repo.base, repo.root, tmpdir());
await go('#/queue', 'Queue'); await seen();
await go('#/queue/6', 'Row 6');
- assert.equal(await text('#bus-view dd.s1-q'), 'token at <path> for <id>');
+ assert.equal(await text('#bus-view dd.s1-q'), NOTE_SHOWN);
+ await seen();
+ // After as the store keeps it, {id, when}: a link to the row and its condition (Filbert B1).
+ const kv = (dt) => b.evaluate(`[...document.querySelectorAll("#bus-view [aria-labelledby=q-row] dt")].find(e=>e.textContent===${JSON.stringify(dt)})?.nextElementSibling?.innerHTML ?? null`);
+ await go('#/queue/9', 'Row 9');
+ assert.equal(await kv('After'), '<a href="#/queue/6">6</a> settled');
+ assert.match(await kv('Required'), /^yes( since |$)/);
+ await go('#/queue/11', 'Row 11');
+ assert.equal(await kv('After'), '<a href="#/queue/9">9</a> done');
+ assert.equal(await kv('Required'), null);
await seen();
await go('#/business', 'Business acme');
assert.match(await text('#bus-view [aria-labelledby=bz-creds]'), /bot-acme-coder/);
+ // The business file's arbiters object, as loadBusiness gives it (Darkwing 27186).
+ assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery), cto (technical)');
await seen();
await go('#/settings', 'Settings');
assert.equal(await text('#bus-view [aria-labelledby=st-notify] dd code'), 'jason-dm');