bus, cli, webui: every message to the human, read state, Messages page (#1588)

Row 94, round 2. Rocko built it, Dewey approved it (comment 28104) on
manifest 53b01ea9 (16 paths, base e918a915). The bus keeps a messages
view with read state, where delivered but not read stays read:false. The
CLI gains messages and follow, and inbox leaves out messages already
read while inbox --json stays byte-compatible. The Console gets a
Messages page and an unread count in the nav. Mos gave the go under
decision 123 (public/ files). Sage gate on 645489e4 plus the patch,
12:52:14Z to 12:55:44Z: bus 115, cli 129, webui 104, all fail 0. Every
scripts/test-*.sh exited 0 (auth 15, conductor 17, config 24, discord
66, extension-package 18, foundation 44, queue 27, release 14, task 98).
Manifest 16/16 before and after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 07:58:36 -05:00
co-authored by Claude Opus 5.5
parent 645489e4cc
commit 2b2a9e14ee
16 changed files with 441 additions and 42 deletions
+22 -1
View File
@@ -555,7 +555,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', 'replies'].includes(request.verb))
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -978,6 +978,27 @@ export class Broker {
};
});
}
// Row 94: the human's message history. Sequence-based cursors remain stable
// as new messages arrive. Successful reads write no delivery or read receipt.
case 'messages': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['limit', 'after']);
if (('limit' in a && !(Number.isSafeInteger(a.limit) && a.limit >= 1 && a.limit <= 200)) ||
('after' in a && !id(a.after))) fail('invalid-request');
let cursor = null;
if ('after' in a) {
cursor = this.#store.get("SELECT seq FROM messages WHERE business=? AND to_role='human' AND id=?", s.business, a.after);
if (!cursor) fail('message-not-found');
}
const counts = this.#store.get(
"SELECT count(*) AS total, coalesce(sum(NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')),0) AS unread FROM messages m WHERE business=? AND to_role='human'", s.business);
const limit = a.limit ?? 50;
const rows = this.#store.all(
"SELECT m.*, EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read') AS read FROM messages m WHERE business=? AND to_role='human' AND (? IS NULL OR m.seq<?) ORDER BY m.seq DESC LIMIT ?",
s.business, cursor?.seq ?? null, cursor?.seq ?? null, limit + 1);
const messages = rows.slice(0, limit).map(m => ({ ...m, read: !!m.read }));
return { messages, ...counts, next: rows.length > limit ? messages.at(-1).id : null };
}
// 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
+1
View File
@@ -8,5 +8,6 @@ export function views(client) {
decisions: (list) => client.call('decisions', { list }),
credentials: () => client.call('credentials'),
replies: (id) => client.call('replies', { id }),
messages: (args = {}) => client.call('messages', args),
});
}