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:
+15
-1
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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)
|
### 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
|
Human resolutions and launch toggles append `human.input`. Agent capabilities
|
||||||
and read-only WebUI capabilities cannot reach these operations. Reader
|
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.
|
business; they do not confer mutation or resolution authority.
|
||||||
|
|
||||||
`decisions` (row 57, #1546) is for human and reader sessions only; a role
|
`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
|
added or removed. Rotating a token file without editing its date changes
|
||||||
neither.
|
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
|
## Shared reads and adapter boundary
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -273,6 +286,7 @@ const read = views(new Client({path,cap}));
|
|||||||
await read.inbox(); await read.tasks(); await read.agents();
|
await read.inbox(); await read.tasks(); await read.agents();
|
||||||
await read.trail('vikunja:3/41');
|
await read.trail('vikunja:3/41');
|
||||||
await read.decisions('roles'); await read.decisions('recent');
|
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`.
|
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const GATED = new Set([
|
|||||||
const CLOSED = "('resolved','withdrawn','expired')";
|
const CLOSED = "('resolved','withdrawn','expired')";
|
||||||
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
|
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
|
||||||
export const RECENT = 50;
|
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.')));
|
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 SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||||
const POLL_KINDS = new Set([
|
const POLL_KINDS = new Set([
|
||||||
@@ -492,7 +494,7 @@ export class Broker {
|
|||||||
if (!object(args)) fail('invalid-request');
|
if (!object(args)) fail('invalid-request');
|
||||||
this.#secretCheck(args);
|
this.#secretCheck(args);
|
||||||
return this.#store.transaction(() => {
|
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');
|
fail('read-only');
|
||||||
const result = this.#dispatch(s, request.verb, args);
|
const result = this.#dispatch(s, request.verb, args);
|
||||||
this.#secretCheck(result);
|
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:
|
default:
|
||||||
fail('unknown-verb');
|
fail('unknown-verb');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export function views(client) {
|
|||||||
trail: (subject) => client.call('trail', { subject }),
|
trail: (subject) => client.call('trail', { subject }),
|
||||||
decisions: (list) => client.call('decisions', { list }),
|
decisions: (list) => client.call('decisions', { list }),
|
||||||
credentials: () => client.call('credentials'),
|
credentials: () => client.call('credentials'),
|
||||||
|
replies: (id) => client.call('replies', { id }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }]]);
|
||||||
|
});
|
||||||
@@ -61,8 +61,8 @@ mosaic launches list [--json]
|
|||||||
- `talk` sends `<text>` to the role as a REQUEST, then polls the human's
|
- `talk` sends `<text>` to the role as a REQUEST, then polls the human's
|
||||||
inbox every 2 s for up to `--wait` seconds (default 900). It prints every
|
inbox every 2 s for up to `--wait` seconds (default 900). It prints every
|
||||||
message that arrives and marks it read, and stops at the reply to its
|
message that arrives and marks it read, and stops at the reply to its
|
||||||
REQUEST. No reply in time exits 1; the reply still lands in the inbox.
|
REQUEST. No reply in time exits 1; the reply may still come, and the next
|
||||||
`--wait 0` only sends.
|
`mosaic talk` prints it. `--wait 0` only sends.
|
||||||
- `launches off` and `on` are the broker's `launch.revoke` and
|
- `launches off` and `on` are the broker's `launch.revoke` and
|
||||||
`launch.restore`. While off, every `role.launch` refuses with
|
`launch.restore`. While off, every `role.launch` refuses with
|
||||||
`launch-revoked`; running sessions keep running. Stop them with `stop`.
|
`launch-revoked`; running sessions keep running. Stop them with `stop`.
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ async function talk(rest, io, deps) {
|
|||||||
if (m.in_reply_to === sent.id) answered = true;
|
if (m.in_reply_to === sent.id) answered = true;
|
||||||
}
|
}
|
||||||
if (answered) return;
|
if (answered) return;
|
||||||
if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk or mosaic inbox shows it`, 1);
|
if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1);
|
||||||
await new Promise((r) => setTimeout(r, pollMs));
|
await new Promise((r) => setTimeout(r, pollMs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,22 @@ test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it
|
|||||||
assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]);
|
assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]);
|
||||||
const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
|
const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
|
||||||
assert.equal(slow.code, 1);
|
assert.equal(slow.code, 1);
|
||||||
assert.match(slow.error, /no reply from coder within 1 s; .*mosaic inbox/);
|
// Row 81 (#1575): only mosaic talk shows a late reply; mosaic inbox lists decisions, never messages.
|
||||||
|
assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and the next mosaic talk shows it");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a late reply the Console has listed still reaches the next talk", async (t) => {
|
||||||
|
const s = setup(t);
|
||||||
|
assert.equal((await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"])).code, 1);
|
||||||
|
const { id } = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1");
|
||||||
|
assert.equal(call(s.b, s.coder, "message.receive")[0].id, id);
|
||||||
|
const answer = call(s.b, s.coder, "message.send", { to: "human", body: "late answer", class: "RESULT", in_reply_to: id });
|
||||||
|
// What the Console's send receipt reads, through its reader session.
|
||||||
|
assert.deepEqual(s.read("replies", { id }).map((m) => m.id), [answer.id]);
|
||||||
|
const next = await s.run(["talk", "coder", "again", "--wait", "1", "--business", "demo"]);
|
||||||
|
assert.equal(next.code, 1, "no answer to the second one");
|
||||||
|
assert.match(next.io.out.text, new RegExp(`coder \\(RESULT, re ${id.slice(0, 8)}\\) ${answer.id.slice(0, 8)}:\nlate answer\n`));
|
||||||
|
assert.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [answer.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => {
|
test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => {
|
||||||
|
|||||||
@@ -180,8 +180,9 @@ On the page:
|
|||||||
memory like a Reply draft. Reloading the page loses it. Send is disabled
|
memory like a Reply draft. Reloading the page loses it. Send is disabled
|
||||||
while a send is pending.
|
while a send is pending.
|
||||||
- Sent shows the message id and links its trail, `#/trail/message/<id>`:
|
- Sent shows the message id and links its trail, `#/trail/message/<id>`:
|
||||||
the message and its deliveries. A reply is not in it. The text is cleared
|
the message and its deliveries. A reply is not in the trail; it shows
|
||||||
unless it was edited during the send.
|
under the receipt (below). The text is cleared unless it was edited
|
||||||
|
during the send.
|
||||||
- Not sent shows the broker's code and keeps the draft. A redirect counts
|
- Not sent shows the broker's code and keeps the draft. A redirect counts
|
||||||
as not sent: the Console never redirects this route, so only the sign-in
|
as not sent: the Console never redirects this route, so only the sign-in
|
||||||
proxy does, when its session has expired. The page asks for a reload to
|
proxy does, when its session has expired. The page asks for a reload to
|
||||||
@@ -192,9 +193,19 @@ On the page:
|
|||||||
unknown, here and on the board's Reply, since a proxy can answer it
|
unknown, here and on the board's Reply, since a proxy can answer it
|
||||||
after forwarding the request (Envoy's `stream_idle_timeout`).
|
after forwarding the request (Envoy's `stream_idle_timeout`).
|
||||||
|
|
||||||
The role's reply comes back as a message to the human. The Console doesn't
|
The role's reply comes back as a message to the human with `in_reply_to`
|
||||||
show it yet; that view is row 81 (#1575). Jason's next `mosaic talk` in a
|
set to the sent message's id. Since row 81 (#1575) the Sent receipt lists
|
||||||
terminal prints it, within talk's limits:
|
those replies, oldest first, each with its role, time, class, body and a
|
||||||
|
trail link. It reads them with the bus `replies` read on every render, so a
|
||||||
|
reply shows on the next ten-second refresh or Read again. "No reply yet"
|
||||||
|
means the read returned none; "could not be read" means it failed, and the
|
||||||
|
next refresh tries again. The bus returns at most 50; at 50 the receipt says
|
||||||
|
only the first 50 show. The list belongs to the last Sent receipt: a new
|
||||||
|
send starts its own, and reloading the page drops it.
|
||||||
|
|
||||||
|
The Console only shows replies. It never receives a message or marks one
|
||||||
|
delivered or read: `replies` writes no `deliveries` row. So Jason's next
|
||||||
|
`mosaic talk` in a terminal still prints each reply, within talk's limits:
|
||||||
|
|
||||||
- talk receives only after it sends its own message;
|
- talk receives only after it sends its own message;
|
||||||
- `--wait 0` returns straight after the send and receives nothing;
|
- `--wait 0` returns straight after the send and receives nothing;
|
||||||
@@ -394,10 +405,13 @@ session reader or transport is implemented here. The session reader is
|
|||||||
`packages/conversation`, served by the board.
|
`packages/conversation`, served by the board.
|
||||||
|
|
||||||
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
|
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
|
||||||
`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent` and
|
`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent`,
|
||||||
`/api/bus/credentials` are the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
|
`/api/bus/credentials` and `/api/bus/replies?id=<message id>` are the bus
|
||||||
the broker's identifier rule, and the list must be `roles` or `recent`, else
|
reads (`src/bus.mjs`). Any other method is 405. The subject and the id must
|
||||||
400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
|
match the broker's identifier rule, and the list must be `roles` or
|
||||||
|
`recent`, else 400. `replies` (row 81, #1575) is the messages whose
|
||||||
|
`in_reply_to` is that id, for the Sent receipt; the bus refuses an id that
|
||||||
|
names no message (`message-not-found`, 400). `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
|
||||||
"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
|
"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
|
||||||
list holds. `credentials` (row 60, #1549) is the bus host's credential
|
list holds. `credentials` (row 60, #1549) is the bus host's credential
|
||||||
status: service, account, role, date and state for each credential it
|
status: service, account, role, date and state for each credential it
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
|
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
|
||||||
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
|
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
|
||||||
// Only the four verbs of the Q1 module (lead decision 56), row 57's
|
// Only the four verbs of the Q1 module (lead decision 56), row 57's
|
||||||
// `decisions` and row 60's `credentials` reads are reachable as reads: no
|
// `decisions`, row 60's `credentials` and row 81's `replies` reads are
|
||||||
// read writes, not even seen (Q4). The one write is row 76's human
|
// reachable as reads: no read writes, not even seen or delivered (Q4). The
|
||||||
// `message.send` (lead decision 95), built by the CLI only with --send. The
|
// one write is row 76's human `message.send` (lead decision 95), built by
|
||||||
// bus does the proof; a server started inside an agent run gets
|
// the CLI only with --send. The bus does the proof; a server started inside
|
||||||
// `human-required` from the broker.
|
// an agent run gets `human-required` from the broker.
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||||
|
|||||||
@@ -24,10 +24,11 @@
|
|||||||
// can show what was read before, labelled as such.
|
// can show what was read before, labelled as such.
|
||||||
const last = new Map();
|
const last = new Map();
|
||||||
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
||||||
|
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||||
async function fetchRead(verb, subject) {
|
async function fetchRead(verb, subject) {
|
||||||
let res, body;
|
let res, body;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
|
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store' });
|
||||||
body = await res.json();
|
body = await res.json();
|
||||||
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||||
@@ -247,19 +248,27 @@
|
|||||||
// a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the
|
// a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the
|
||||||
// 10 s refresh redraws the form without losing it. Nothing resends: an unknown outcome stays
|
// 10 s refresh redraws the form without losing it. Nothing resends: an unknown outcome stays
|
||||||
// unknown and the draft stays put.
|
// unknown and the draft stays put.
|
||||||
let canSend = false, sendRoles = null, sending = false, receipt = null;
|
// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each
|
||||||
|
// render. That read marks nothing, so every reply still reaches the next `mosaic talk`.
|
||||||
|
let canSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
|
||||||
|
const REPLIES = 50; // the broker's cap
|
||||||
|
function repliesHtml() {
|
||||||
|
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic talk</code> in a terminal prints them.</p>';
|
||||||
|
if (!replies.length) return '<p class="small muted">No reply yet. One shows here when the role answers, as the page refreshes.</p>';
|
||||||
|
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
|
||||||
|
}
|
||||||
const draft = { to: '', body: '' };
|
const draft = { to: '', body: '' };
|
||||||
function composerHtml() {
|
function composerHtml() {
|
||||||
const roles = sendRoles ? [...new Set([...sendRoles, ...(draft.to && !sendRoles.includes(draft.to) ? [draft.to] : [])])] : null;
|
const roles = sendRoles ? [...new Set([...sendRoles, ...(draft.to && !sendRoles.includes(draft.to) ? [draft.to] : [])])] : null;
|
||||||
const options = roles ? roles.map(r => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
|
const options = roles ? roles.map(r => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
|
||||||
const r = receipt;
|
const r = receipt;
|
||||||
const said = !r ? '' : r.kind === 'sent'
|
const said = !r ? '' : r.kind === 'sent'
|
||||||
? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>`
|
? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>${repliesHtml()}`
|
||||||
: r.kind === 'refused'
|
: r.kind === 'refused'
|
||||||
? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
|
? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
|
||||||
: `<p class="receipt failed">Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.</p>`;
|
: `<p class="receipt failed">Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.</p>`;
|
||||||
return `<section class="panel s1-send" id="send-section" aria-labelledby="b-send"><h2 id="b-send">Message a role</h2>
|
return `<section class="panel s1-send" id="send-section" aria-labelledby="b-send"><h2 id="b-send">Message a role</h2>
|
||||||
<p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. A reply comes back to you as a message, which this Console does not show yet. The next <code>mosaic talk</code> in a terminal prints it, after sending its own message.</p>
|
<p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. Replies to it show under the receipt. The Console only shows them and never marks one delivered or read, so the next <code>mosaic talk</code> in a terminal still prints each one.</p>
|
||||||
${roles === null ? '<p class="small warn">The held roles could not be read, so there is nothing to pick. Read again to retry.</p>' : !roles.length ? '<p class="small muted">No role is held right now, so there is nothing to pick.</p>' : ''}
|
${roles === null ? '<p class="small warn">The held roles could not be read, so there is nothing to pick. Read again to retry.</p>' : !roles.length ? '<p class="small muted">No role is held right now, so there is nothing to pick.</p>' : ''}
|
||||||
<form id="send-form" class="reply-form" novalidate><label for="send-to">To</label><select id="send-to" data-field="to"><option value="">Choose a held role</option>${options}</select>
|
<form id="send-form" class="reply-form" novalidate><label for="send-to">To</label><select id="send-to" data-field="to"><option value="">Choose a held role</option>${options}</select>
|
||||||
<label for="send-body">Message</label><textarea id="send-body" data-field="body" rows="4" placeholder="What should the role do?">${esc(draft.body)}</textarea>
|
<label for="send-body">Message</label><textarea id="send-body" data-field="body" rows="4" placeholder="What should the role do?">${esc(draft.body)}</textarea>
|
||||||
@@ -268,6 +277,13 @@
|
|||||||
async function composer(get) {
|
async function composer(get) {
|
||||||
if (!canSend) return '';
|
if (!canSend) return '';
|
||||||
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
|
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
|
||||||
|
if (receipt?.kind === 'sent') {
|
||||||
|
const r = receipt;
|
||||||
|
let rows = null;
|
||||||
|
try { rows = await get('replies', r.id); } catch {}
|
||||||
|
// A send that finished during this read has its own receipt; leave it alone.
|
||||||
|
if (receipt === r) replies = Array.isArray(rows) ? rows : null;
|
||||||
|
}
|
||||||
return composerHtml();
|
return composerHtml();
|
||||||
}
|
}
|
||||||
// Redraw only the form, keeping focus, after a send changes its state.
|
// Redraw only the form, keeping focus, after a send changes its state.
|
||||||
@@ -772,7 +788,7 @@
|
|||||||
if (res?.type === 'opaqueredirect') {
|
if (res?.type === 'opaqueredirect') {
|
||||||
receipt = { kind: 'refused', to, code: 'sign-in', message: 'the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again' };
|
receipt = { kind: 'refused', to, code: 'sign-in', message: 'the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again' };
|
||||||
} else if (res?.ok && SAFE.test(body?.id ?? '')) {
|
} else if (res?.ok && SAFE.test(body?.id ?? '')) {
|
||||||
receipt = { kind: 'sent', to, id: body.id, at: body.at };
|
receipt = { kind: 'sent', to, id: body.id, at: body.at }; replies = [];
|
||||||
if (draft.body === text) draft.body = ''; // keep text typed while the send was in flight
|
if (draft.body === text) draft.body = ''; // keep text typed while the send was in flight
|
||||||
} else if (res && !res.ok && ((res.status >= 400 && res.status < 500 && res.status !== 408) || body?.sent === false)) {
|
} else if (res && !res.ok && ((res.status >= 400 && res.status < 500 && res.status !== 408) || body?.sent === false)) {
|
||||||
receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message.replace(/^not sent: /, '') : 'refused' };
|
receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message.replace(/^not sent: /, '') : 'refused' };
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ async function body(req, max = 4096) {
|
|||||||
}
|
}
|
||||||
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
||||||
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
||||||
// (#1549) adds `credentials`: what the bus host loaded, metadata only.
|
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
|
||||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'];
|
// (#1575) adds `replies`: the messages answering one message, for the send receipt.
|
||||||
|
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'];
|
||||||
const DECISION_LISTS = ['roles', 'recent'];
|
const DECISION_LISTS = ['roles', 'recent'];
|
||||||
async function busRead(res, bus, verb, search) {
|
async function busRead(res, bus, verb, search) {
|
||||||
let arg;
|
let arg;
|
||||||
@@ -60,6 +61,10 @@ async function busRead(res, bus, verb, search) {
|
|||||||
arg = new URLSearchParams(search).get('list');
|
arg = new URLSearchParams(search).get('list');
|
||||||
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
|
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
|
||||||
}
|
}
|
||||||
|
if (verb === 'replies') {
|
||||||
|
arg = new URLSearchParams(search).get('id');
|
||||||
|
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
|
||||||
|
}
|
||||||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||||
try {
|
try {
|
||||||
const rows = await bus[verb](arg);
|
const rows = await bus[verb](arg);
|
||||||
|
|||||||
@@ -150,9 +150,53 @@ test('credentials route serves the broker credential status from the reader view
|
|||||||
assert.deepEqual(calls, [['credentials', {}]]);
|
assert.deepEqual(calls, [['credentials', {}]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row 81 (#1575): the replies to one message, for the Console's send receipt. The bus refuses an
|
||||||
|
// id that names no message; a malformed one never reaches it.
|
||||||
|
test('replies route serves the replies to one message from the reader view', async t => {
|
||||||
|
const store = new Store(tmp(t, 'webui-bus-replies-'));
|
||||||
|
t.after(() => store.close());
|
||||||
|
const b = new Broker({ store, businesses });
|
||||||
|
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||||
|
b.request(coder, { verb: 'role.claim' });
|
||||||
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
|
const sent = b.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look.' } });
|
||||||
|
const quiet = b.request(human, { verb: 'message.send', args: { to: 'coder', body: 'No answer to this one.' } });
|
||||||
|
const answer = b.request(coder, { verb: 'message.send', args: { to: 'human', body: `Done. ${HOSTILE}`, class: 'RESULT', in_reply_to: sent.id } });
|
||||||
|
const reader = b.bindReader({ business: 'demo' });
|
||||||
|
const calls = [];
|
||||||
|
// A broker refusal comes back from the transport as a BusReadError carrying its code.
|
||||||
|
const call = async (verb, args = {}) => {
|
||||||
|
calls.push([verb, args]);
|
||||||
|
try { return structuredClone(b.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
|
||||||
|
};
|
||||||
|
const bus = views({ call });
|
||||||
|
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||||
|
t.after(() => close(web));
|
||||||
|
const base = `http://127.0.0.1:${web.address().port}`;
|
||||||
|
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
|
||||||
|
const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq');
|
||||||
|
const before = deliveries();
|
||||||
|
|
||||||
|
const r = await read('/api/bus/replies?id=' + sent.id);
|
||||||
|
assert.equal(r.status, 200); assert.equal(r.cache, 'no-store');
|
||||||
|
assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/);
|
||||||
|
assert.deepEqual(r.body.rows.map(m => [m.id, m.from_role, m.class, m.in_reply_to, m.body]), [[answer.id, 'coder', 'RESULT', sent.id, `Done. ${HOSTILE}`]]);
|
||||||
|
assert.deepEqual((await read('/api/bus/replies?id=' + quiet.id)).body.rows, []);
|
||||||
|
const unknown = await read('/api/bus/replies?id=00000000-0000-4000-8000-000000000000');
|
||||||
|
assert.deepEqual([unknown.status, unknown.body.error], [400, 'message-not-found']);
|
||||||
|
assert.deepEqual(deliveries(), before, 'no delivered or read row');
|
||||||
|
assert.deepEqual(calls, [['replies', { id: sent.id }], ['replies', { id: quiet.id }], ['replies', { id: '00000000-0000-4000-8000-000000000000' }]]);
|
||||||
|
|
||||||
|
// A missing or malformed id never reaches the bus; trail's `subject` is not this read's argument.
|
||||||
|
const n = calls.length;
|
||||||
|
for (const q of ['', '?id=', '?id=' + 'a'.repeat(161), '?id=-x', '?id=a%20b', '?id=%3Cb%3E', '?subject=' + sent.id])
|
||||||
|
assert.deepEqual([q, (await read('/api/bus/replies' + q)).body.error], [q, 'invalid-request']);
|
||||||
|
assert.equal(calls.length, n);
|
||||||
|
});
|
||||||
|
|
||||||
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
|
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'].map(v => [v, async () => { calls.push(v); return []; }]));
|
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'].map(v => [v, async () => { calls.push(v); return []; }]));
|
||||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||||
t.after(() => close(web));
|
t.after(() => close(web));
|
||||||
const base = `http://127.0.0.1:${web.address().port}`;
|
const base = `http://127.0.0.1:${web.address().port}`;
|
||||||
@@ -160,6 +204,7 @@ test('bus routes are GET-only, have no write verb, and keep the same-origin chec
|
|||||||
assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
|
assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
|
||||||
assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { method })).status, 405, method);
|
assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { method })).status, 405, method);
|
||||||
assert.equal((await fetch(base + '/api/bus/credentials', { method })).status, 405, method);
|
assert.equal((await fetch(base + '/api/bus/credentials', { method })).status, 405, method);
|
||||||
|
assert.equal((await fetch(base + '/api/bus/replies?id=m-1', { method })).status, 405, method);
|
||||||
}
|
}
|
||||||
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
|
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
|
||||||
assert.equal((await fetch(base + path)).status, 404, path);
|
assert.equal((await fetch(base + path)).status, 404, path);
|
||||||
@@ -192,7 +237,7 @@ test('bus refusals map to statuses and never carry a path or a stack', async t =
|
|||||||
const unset = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/agents`);
|
const unset = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/agents`);
|
||||||
assert.deepEqual([unset.status, (await unset.json()).error], [503, 'not-configured']);
|
assert.deepEqual([unset.status, (await unset.json()).error], [503, 'not-configured']);
|
||||||
// Row 67 (#1560): with no bus, a bad argument is still a 400; the request is checked first.
|
// Row 67 (#1560): with no bus, a bad argument is still a 400; the request is checked first.
|
||||||
for (const [query, status, error] of [['decisions?list=bad', 400, 'invalid-request'], ['decisions?list=roles', 503, 'not-configured'], ['trail?subject=%3C', 400, 'invalid-request']]) {
|
for (const [query, status, error] of [['decisions?list=bad', 400, 'invalid-request'], ['decisions?list=roles', 503, 'not-configured'], ['trail?subject=%3C', 400, 'invalid-request'], ['replies?id=%3C', 400, 'invalid-request'], ['replies?id=m-1', 503, 'not-configured']]) {
|
||||||
const r = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/${query}`);
|
const r = await fetch(`http://127.0.0.1:${none.address().port}/api/bus/${query}`);
|
||||||
assert.deepEqual([query, r.status, (await r.json()).error], [query, status, error]);
|
assert.deepEqual([query, r.status, (await r.json()).error], [query, status, error]);
|
||||||
}
|
}
|
||||||
@@ -250,6 +295,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t
|
|||||||
assert.equal((await pinned.inbox()).request.business, 'pinned');
|
assert.equal((await pinned.inbox()).request.business, 'pinned');
|
||||||
assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
|
assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
|
||||||
assert.deepEqual((await pinned.credentials()).request, { business: 'pinned', verb: 'credentials', args: {} });
|
assert.deepEqual((await pinned.credentials()).request, { business: 'pinned', verb: 'credentials', args: {} });
|
||||||
|
assert.deepEqual((await pinned.replies('m-1')).request, { business: 'pinned', verb: 'replies', args: { id: 'm-1' } });
|
||||||
|
|
||||||
const reader = busReader({ dataRoot, socket: 'sock', cli });
|
const reader = busReader({ dataRoot, socket: 'sock', cli });
|
||||||
const code = () => reader.agents().then(() => 'resolved', e => e.code);
|
const code = () => reader.agents().then(() => 'resolved', e => e.code);
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import { Store } from '../../bus/src/store.mjs';
|
|||||||
import { Broker } from '../../bus/src/broker.mjs';
|
import { Broker } from '../../bus/src/broker.mjs';
|
||||||
import { views } from '../../bus/src/views.mjs';
|
import { views } from '../../bus/src/views.mjs';
|
||||||
import { startServer } from '../src/serve.mjs';
|
import { startServer } from '../src/serve.mjs';
|
||||||
|
import { BusReadError } from '../src/bus.mjs';
|
||||||
import { browser } from './browser.mjs';
|
import { browser } from './browser.mjs';
|
||||||
import { fixture, close } from './fixture.mjs';
|
import { fixture, close } from './fixture.mjs';
|
||||||
|
|
||||||
|
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
|
||||||
const businesses = {
|
const businesses = {
|
||||||
demo: {
|
demo: {
|
||||||
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||||
@@ -100,7 +102,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
|||||||
await b.navigate(`${base}#/inbox`);
|
await b.navigate(`${base}#/inbox`);
|
||||||
await wait('document.querySelector("#send-form")');
|
await wait('document.querySelector("#send-form")');
|
||||||
assert.equal(await text('#send-section h2'), 'Message a role');
|
assert.equal(await text('#send-section h2'), 'Message a role');
|
||||||
assert.match(await text('#send-section'), /does not show yet\. The next mosaic talk in a terminal prints it, after sending its own message\./);
|
assert.match(await text('#send-section'), /Replies to it show under the receipt\. The Console only shows them and never marks one delivered or read, so the next mosaic talk in a terminal still prints each one\./);
|
||||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent])'), [['', 'Choose a held role'], ['coder', 'coder'], ['pm', 'pm']]);
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent])'), [['', 'Choose a held role'], ['coder', 'coder'], ['pm', 'pm']]);
|
||||||
|
|
||||||
// Nothing to send is refused in the page, without a request.
|
// Nothing to send is refused in the page, without a request.
|
||||||
@@ -193,3 +195,135 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each
|
||||||
|
// render. The read writes no delivery or read row, so the human's next message.receive
|
||||||
|
// (mosaic talk) still gets every reply the page showed.
|
||||||
|
test('a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read', { timeout: 120000 }, async () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'webui-replies-browser-'));
|
||||||
|
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||||
|
const caps = {};
|
||||||
|
for (const role of ['pm', 'coder']) b0.request(caps[role] = b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' });
|
||||||
|
const human = b0.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||||
|
const sent = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look at row 81.' } });
|
||||||
|
const other = b0.request(human, { verb: 'message.send', args: { to: 'pm', body: 'Another question' } });
|
||||||
|
const reader = b0.bindReader({ business: 'demo' });
|
||||||
|
// A broker refusal comes back from the transport as a BusReadError carrying its code. While
|
||||||
|
// `hold` is set, a replies read waits for it, counted in `held`.
|
||||||
|
let hold = null, held = 0;
|
||||||
|
const bus = views({
|
||||||
|
call: async (verb, args = {}) => {
|
||||||
|
if (verb === 'replies' && hold) { held++; await hold.promise; }
|
||||||
|
try { return structuredClone(b0.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const f = await fixture(), b = await browser();
|
||||||
|
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||||
|
const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||||
|
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||||
|
const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
|
||||||
|
const send = async (id, body) => {
|
||||||
|
await set('#send-body', body);
|
||||||
|
await b.evaluate(`window.__answers.push(${JSON.stringify({ status: 200, body: { id, at: new Date().toISOString() } })}), true`);
|
||||||
|
await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
|
||||||
|
await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"');
|
||||||
|
};
|
||||||
|
const refresh = async () => {
|
||||||
|
await b.evaluate('(()=>{document.querySelector("#send-section").dataset.before="1";document.querySelector("#refresh").click();return true})()');
|
||||||
|
await wait('!document.querySelector("#send-section")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
|
||||||
|
};
|
||||||
|
const items = () => b.evaluate('[...document.querySelectorAll("#send-section .s1-replies li")].map(li => [...li.querySelectorAll("p")].map(p => p.textContent).concat(li.querySelector("a").getAttribute("href")))');
|
||||||
|
const reply = (role, to, body) => b0.request(caps[role], { verb: 'message.send', args: { to: 'human', body, class: 'RESULT', ...(to ? { in_reply_to: to } : {}) } });
|
||||||
|
const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq').map(d => ({ ...d }));
|
||||||
|
const NONE = 'No reply yet. One shows here when the role answers, as the page refreshes.';
|
||||||
|
try {
|
||||||
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||||||
|
await b.viewport(1280);
|
||||||
|
await b.navigate(`${base}#/inbox`);
|
||||||
|
await wait('document.querySelector("#send-form")');
|
||||||
|
await set('#send-to', 'coder');
|
||||||
|
|
||||||
|
// Straight after the send there is no reply.
|
||||||
|
await send(sent.id, 'Please look at row 81.');
|
||||||
|
assert.match(await text('#send-section .receipt'), new RegExp(`^Sent to coder .*, message ${sent.id.slice(0, 8)}\\. Its trail\\.$`));
|
||||||
|
assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
|
||||||
|
assert.deepEqual(await items(), []);
|
||||||
|
|
||||||
|
// Replies to the sent message show on Read again, oldest first, as text. A reply to another
|
||||||
|
// message and a message with no in_reply_to don't.
|
||||||
|
const first = reply('coder', sent.id, `Done: 3 files. ${HOSTILE}`);
|
||||||
|
reply('pm', other.id, 'Another answer');
|
||||||
|
reply('pm', null, 'fyi');
|
||||||
|
const second = reply('pm', sent.id, 'Second look.');
|
||||||
|
let before = deliveries();
|
||||||
|
await refresh();
|
||||||
|
assert.deepEqual(deliveries(), before, 'no delivered or read row');
|
||||||
|
assert.equal(await text('#send-section h3'), 'Replies 2');
|
||||||
|
const two = await items();
|
||||||
|
assert.deepEqual(two.map(([meta, , href]) => [meta.split(' ')[0], / · RESULT · trail$/.test(meta), href]), [['coder', true, `#/trail/message/${first.id}`], ['pm', true, `#/trail/message/${second.id}`]]);
|
||||||
|
assert.match(two[0][1], /^Done: 3 files\. <script>window\.injected=1<\/script><img src=x onerror="window\.injected=2">/);
|
||||||
|
assert.equal(two[1][1], 'Second look.');
|
||||||
|
assert.equal(await b.evaluate('!!window.injected || !!document.querySelector("#send-section script, #send-section img")'), false);
|
||||||
|
assert.match(await text('#send-section .receipt'), /^Sent to coder /);
|
||||||
|
assert.doesNotMatch(await text('#send-section'), /The first 50 replies/);
|
||||||
|
|
||||||
|
// The page's own refresh reads them too, with no click.
|
||||||
|
const third = reply('coder', sent.id, 'Third.');
|
||||||
|
before = deliveries();
|
||||||
|
await wait('document.querySelectorAll("#send-section .s1-replies li").length === 3', 300);
|
||||||
|
assert.deepEqual((await items()).map(i => i[1]).slice(1), ['Second look.', 'Third.']);
|
||||||
|
|
||||||
|
// Showing them wrote nothing: mosaic talk's message.receive still gets each one, and once
|
||||||
|
// the human has received and read them they are still listed.
|
||||||
|
assert.deepEqual(deliveries(), before, 'no delivered or read row');
|
||||||
|
const got = b0.request(human, { verb: 'message.receive' });
|
||||||
|
assert.deepEqual(got.filter(m => m.in_reply_to === sent.id).map(m => m.id), [first.id, second.id, third.id]);
|
||||||
|
for (const m of got) b0.request(human, { verb: 'message.read', args: { id: m.id } });
|
||||||
|
await refresh();
|
||||||
|
assert.equal((await items()).length, 3);
|
||||||
|
|
||||||
|
// The broker's cap: 51 replies show the first 50 and say so.
|
||||||
|
for (let i = 0; i < 48; i++) reply(i % 2 ? 'pm' : 'coder', sent.id, `reply ${i}`);
|
||||||
|
await refresh();
|
||||||
|
const capped = await items();
|
||||||
|
assert.equal(capped.length, 50);
|
||||||
|
assert.deepEqual([capped[0][1].slice(0, 5), capped[3][1], capped[49][1]], ['Done:', 'reply 0', 'reply 46']);
|
||||||
|
assert.equal(await text('#send-section h3'), 'Replies 50');
|
||||||
|
assert.match(await text('#send-section'), /The first 50 replies show here\./);
|
||||||
|
|
||||||
|
// A sent id the bus doesn't know: its replies can't be read, which the receipt says; the
|
||||||
|
// Inbox stays fresh.
|
||||||
|
await send('00000000-0000-4000-8000-000000000000', 'Lost?');
|
||||||
|
assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
|
||||||
|
await refresh();
|
||||||
|
assert.match(await text('#send-section'), /The replies to it could not be read\. Read again to retry; mosaic talk in a terminal prints them\./);
|
||||||
|
assert.deepEqual(await items(), []);
|
||||||
|
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .s1-stale, #bus-view .banner")'), false);
|
||||||
|
assert.match(await text('#send-section .receipt'), /, message 00000000\. Its trail\.$/);
|
||||||
|
|
||||||
|
// A new send starts its own list.
|
||||||
|
await send(sent.id, 'Once more.');
|
||||||
|
assert.deepEqual(await items(), []);
|
||||||
|
await refresh();
|
||||||
|
assert.equal((await items()).length, 50);
|
||||||
|
|
||||||
|
// A send that finishes while a replies read is out keeps its own list: that read's answer is
|
||||||
|
// the earlier message's replies, so it is dropped and the next render reads the new one's.
|
||||||
|
let release;
|
||||||
|
hold = { promise: new Promise(r => { release = r; }) };
|
||||||
|
await b.evaluate('(()=>{document.querySelector("#bus-view h1").dataset.before="1";document.querySelector("#refresh").click();return true})()');
|
||||||
|
for (let i = 0; i < 100 && !held; i++) await new Promise(r => setTimeout(r, 50));
|
||||||
|
assert.equal(held, 1);
|
||||||
|
await send(other.id, 'Another?');
|
||||||
|
hold = null; release();
|
||||||
|
await wait('!document.querySelector("#bus-view h1")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
|
||||||
|
assert.match(await text('#send-section .receipt'), new RegExp(`, message ${other.id.slice(0, 8)}\\. Its trail\\.$`));
|
||||||
|
assert.deepEqual(await items(), []);
|
||||||
|
assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
|
||||||
|
await refresh();
|
||||||
|
assert.deepEqual((await items()).map(i => i[1]), ['Another answer']);
|
||||||
|
} finally {
|
||||||
|
await b.close(); await close(web); await f.close(); store.close();
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user