bus, webui, cli: the Console shows the reply to a web send (#1575)

Row 81. The broker gains a `replies` read verb for human and reader
sessions: `{id}`, a message UUID, returns that business's messages whose
in_reply_to is the id, oldest first, at most 50, and writes no
deliveries row, so the reply still reaches the next `mosaic talk`. An
unknown id refuses message-not-found. The Console serves it through
BUS_VERBS with an id check, and the Sent receipt shows the replies to a
web send. The cli's talk timeout message and README now say the reply
may still come and the next `mosaic talk` prints it.

Dewey's round 2, candidate manifest 3ce7527f, base 1e5f5958, a rebase
after row 61. Approved by Darkwing (27783 round 1, 27821 round 2).
Sage's gate green 19:25:18Z to 19:30:39Z. Live at the next bus restart
(the broker verb) and Console restart (the view), bus first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 14:33:11 -05:00
co-authored by Claude Opus 5.5
parent 9438880e67
commit c9a5650ed0
13 changed files with 455 additions and 30 deletions
+15 -1
View File
@@ -130,6 +130,7 @@ an operator inspect content and delivery evidence after an uncertain result.
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
### Agent state (row 59, #1548)
@@ -226,7 +227,7 @@ must not invoke the human CLI or work around its proof.
Human resolutions and launch toggles append `human.input`. Agent capabilities
and read-only WebUI capabilities cannot reach these operations. Reader
capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials for one
capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies for one
business; they do not confer mutation or resolution authority.
`decisions` (row 57, #1546) is for human and reader sessions only; a role
@@ -264,6 +265,18 @@ business file changes after startup: a date, an account, or a credential
added or removed. Rotating a token file without editing its date changes
neither.
`replies` (row 81, #1575) is also for human and reader sessions only; a role
session gets `human-required`. It takes `{id}`, nothing else, and returns
the business's messages whose `in_reply_to` is that id, in broker write
order, at most `REPLIES` (50). Each row is the `messages` row `trail` gives
the same session for the reply's id. An id that names no message in the
session's business refuses `message-not-found`, as `message.send` does for
`in_reply_to`; `trail` returns an empty list for such an id instead. The
read writes no `deliveries` row, so a reply the WebUI's Sent receipt lists
still reaches the human's next `message.receive` (`mosaic talk`). It is a
separate verb rather than a `trail` field so `trail`'s shape and its
empty-list answer for unknown subjects stay as they are.
## Shared reads and adapter boundary
```js
@@ -273,6 +286,7 @@ const read = views(new Client({path,cap}));
await read.inbox(); await read.tasks(); await read.agents();
await read.trail('vikunja:3/41');
await read.decisions('roles'); await read.decisions('recent');
await read.replies(messageId);
```
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
+19 -1
View File
@@ -40,6 +40,8 @@ const GATED = new Set([
const CLOSED = "('resolved','withdrawn','expired')";
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
export const RECENT = 50;
// How many replies `replies` returns, oldest first.
export const REPLIES = 50;
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
@@ -492,7 +494,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials'].includes(request.verb))
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -906,6 +908,22 @@ export class Broker {
};
});
}
// Row 81 (#1575): the replies to a message, for the Console's web-send receipt. Human and
// reader sessions only, like decisions. It writes no delivery or read row, so a reply it
// lists still reaches the next `mosaic talk`. An id naming no message in the business
// refuses message-not-found, as message.send does for in_reply_to; trail would return [].
case 'replies': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['id'], ['id']);
identifier(a.id);
if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
fail('message-not-found');
return this.#store.all(
`SELECT * FROM messages WHERE business=? AND in_reply_to=? ORDER BY seq LIMIT ${REPLIES}`,
s.business,
a.id,
);
}
default:
fail('unknown-verb');
}
+1
View File
@@ -7,5 +7,6 @@ export function views(client) {
trail: (subject) => client.call('trail', { subject }),
decisions: (list) => client.call('decisions', { list }),
credentials: () => client.call('credentials'),
replies: (id) => client.call('replies', { id }),
});
}
+162
View File
@@ -0,0 +1,162 @@
// Row 81 (#1575): `replies`, the read behind the Console's web-send receipt. The messages
// answering one message, oldest first, at most REPLIES, for human and reader sessions. It
// writes no delivery or read row, so the human's next message.receive (mosaic talk) still
// gets each reply.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, REPLIES } from '../src/broker.mjs';
import { views } from '../src/views.mjs';
const roles = {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
};
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
};
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-replies-'));
const store = new Store(root);
const b = new Broker({ store, businesses });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const agent = (role, business = 'demo') => {
const cap = b.bindLaunch({ business, role, run: `${business}-${role}-run`, harness: 'pi', address: `${business}-${role}-run` });
b.request(cap, { verb: 'role.claim' });
return cap;
};
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = b.bindReader({ business: 'demo' });
const coder = agent('coder');
const pm = agent('pm');
// The human's web send, as row 76's Console makes it.
const sent = call(b, human, 'message.send', { to: 'coder', body: 'Please look at row 81.' });
const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq');
const count = () =>
['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
(table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
);
return { b, store, agent, human, reader, coder, pm, sent, deliveries, count };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
const reply = (b, cap, to, body, extra = {}) =>
call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra });
test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => {
const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t);
call(b, coder, 'message.receive');
const answer = reply(b, coder, sent.id, 'Done: 3 files.');
// Not replies to the send: a message to the human with no in_reply_to, and a reply to another message.
call(b, pm, 'message.send', { to: 'human', body: 'fyi' });
const other = call(b, human, 'message.send', { to: 'pm', body: 'Another question' });
reply(b, pm, other.id, 'Another answer');
const before = deliveries();
const counts = count();
for (const cap of [human, reader]) {
const rows = call(b, cap, 'replies', { id: sent.id });
assert.deepEqual(
rows.map((m) => [m.id, m.from_role, m.to_role, m.class, m.in_reply_to, m.body]),
[[answer.id, 'coder', 'human', 'RESULT', sent.id, 'Done: 3 files.']],
);
// The whole row is the messages row trail gives the same session for that id.
const { table, ...m } = call(b, cap, 'trail', { subject: answer.id }).find((r) => r.table === 'messages');
assert.equal(table, 'messages');
assert.deepEqual({ ...rows[0] }, m);
}
assert.deepEqual(deliveries(), before, 'no delivered or read row, nothing else either');
assert.deepEqual(count(), counts, 'a successful read writes nothing');
assert.deepEqual(
store.all("SELECT op FROM deliveries WHERE message=? ORDER BY seq", answer.id).map((d) => d.op),
['routed'],
);
});
test('the reply still reaches the human message.receive (mosaic talk) after the Console showed it', (t) => {
const { b, human, reader, coder, sent } = setup(t);
const answer = reply(b, coder, sent.id, 'Done.');
assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
assert.deepEqual(call(b, human, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
const got = call(b, human, 'message.receive');
assert.deepEqual(got.map((m) => [m.id, m.in_reply_to, m.body]), [[answer.id, sent.id, 'Done.']]);
assert.deepEqual(call(b, human, 'message.read', { id: answer.id }), { read: true });
assert.deepEqual(call(b, human, 'message.receive'), []);
// Once received and read it is still listed: the read is history, not an inbox.
assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
});
test('a message with no reply gives an empty list', (t) => {
const { b, human, reader, sent } = setup(t);
for (const cap of [human, reader]) assert.deepEqual(call(b, cap, 'replies', { id: sent.id }), []);
});
test('replies come oldest first and stop at REPLIES', (t) => {
const { b, reader, coder, pm, sent } = setup(t);
assert.equal(REPLIES, 50);
const ids = [];
for (let i = 0; i <= REPLIES; i++) ids.push(reply(b, i % 2 ? pm : coder, sent.id, `reply ${i}`).id);
const rows = call(b, reader, 'replies', { id: sent.id });
assert.equal(rows.length, REPLIES);
assert.deepEqual(
rows.map((m) => m.id),
ids.slice(0, REPLIES),
);
assert.deepEqual(rows.slice(0, 3).map((m) => [m.from_role, m.body]), [
['coder', 'reply 0'],
['pm', 'reply 1'],
['coder', 'reply 2'],
]);
});
test('an id that names no message in the business refuses message-not-found, and the refusal is recorded', (t) => {
const { b, store, agent, human, reader, pm, sent } = setup(t);
const elsewhere = call(b, agent('coder', 'other'), 'message.send', { to: 'human', body: 'not demo' });
const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
for (const cap of [human, reader])
for (const id of ['00000000-0000-4000-8000-000000000000', elsewhere.id, 'vikunja:3/1']) {
const before = refusals();
assert.throws(() => call(b, cap, 'replies', { id }), { code: 'message-not-found' }, id);
assert.equal(refusals(), before + 1, 'the refusal is recorded');
}
// A decision id is not a message id either.
const d = call(b, pm, 'decision.raise', {
action: 'deploy',
target: 'release-1',
question: 'Deploy?',
options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }],
recommendation: 'yes',
blocking: false,
});
assert.throws(() => call(b, reader, 'replies', { id: d.id }), { code: 'message-not-found' });
// The other business's reader finds its own message, not demo's.
const otherReader = b.bindReader({ business: 'other' });
assert.deepEqual(call(b, otherReader, 'replies', { id: elsewhere.id }), []);
assert.throws(() => call(b, otherReader, 'replies', { id: sent.id }), { code: 'message-not-found' });
});
test('strict arguments; a role session gets human-required', (t) => {
const { b, human, reader, coder, pm, sent } = setup(t);
for (const cap of [human, reader])
for (const args of [{}, { id: '' }, { id: 7 }, { id: [sent.id] }, { id: '<b>' }, { id: sent.id, limit: 5 }, { subject: sent.id }])
assert.throws(() => call(b, cap, 'replies', args), { code: 'invalid-request' }, JSON.stringify(args));
// A role can't read the human's replies, even to a message it sent itself.
const own = call(b, pm, 'message.send', { to: 'coder', body: 'hi' });
for (const [cap, id] of [[coder, sent.id], [pm, own.id]])
assert.throws(() => call(b, cap, 'replies', { id }), { code: 'human-required' });
// The reader's other limits stand.
assert.throws(() => call(b, reader, 'message.receive'), { code: 'read-only' });
});
test('views passes replies through as one call with the id', async () => {
const calls = [];
const read = views({ call: async (verb, args) => (calls.push([verb, args]), []) });
assert.deepEqual(await read.replies('m-1'), []);
assert.deepEqual(calls, [['replies', { id: 'm-1' }]]);
});