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]>
163 lines
8.0 KiB
JavaScript
163 lines
8.0 KiB
JavaScript
// 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' }]]);
|
|
});
|