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, basee918a915). 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 on645489e4plus 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:
+14
-1
@@ -229,7 +229,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/replies for one
|
||||
capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies/messages for one
|
||||
business; they do not confer mutation or resolution authority.
|
||||
|
||||
`decisions` (row 57, #1546) is for human and reader sessions only; a role
|
||||
@@ -279,6 +279,18 @@ 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.
|
||||
|
||||
`messages` (row 94, #1588) is a read for human and reader sessions only.
|
||||
It takes `{limit?, after?}`; `limit` defaults to 50 and must be an integer
|
||||
from 1 to 200. It returns `{messages, total, unread, next}`. Messages are
|
||||
only those addressed to `human` in this capability's business, newest
|
||||
first by immutable sequence. Each message adds `read`, true only when a
|
||||
read receipt exists. Counts cover the entire feed. `next` is the last
|
||||
returned message id if another page exists, otherwise null; pass it as
|
||||
`after` to read older messages. A cursor that is not in this human feed
|
||||
refuses `message-not-found`; malformed arguments refuse `invalid-request`.
|
||||
Roles get `human-required`. A successful read records nothing, so Console
|
||||
history reads never prevent a later `message.receive` from delivering it.
|
||||
|
||||
## Shared reads and adapter boundary
|
||||
|
||||
```js
|
||||
@@ -289,6 +301,7 @@ 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);
|
||||
await read.messages({limit: 50});
|
||||
```
|
||||
|
||||
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { BUSINESSES, broker } from '../../cli/tests/helpers.mjs';
|
||||
import { views } from '../src/views.mjs';
|
||||
const call = (s, cap, verb, args = {}) => s.b.request(cap, { verb, args });
|
||||
const send = (s, body, to = 'human') => call(s, s.coder, 'message.send', { to, body, class: 'INFO' }).id;
|
||||
test('messages pages all human messages newest first without writes; read state is history', t => {
|
||||
const s = broker(t), ids = [];
|
||||
for (let i = 0; i < 205; i++) ids.push(send(s, `note ${i}`));
|
||||
send(s, 'role only', 'pm');
|
||||
const before = s.store.all('SELECT * FROM deliveries');
|
||||
const first = s.read('messages');
|
||||
assert.equal(first.messages.length, 50);
|
||||
assert.equal(first.total, 205); assert.equal(first.unread, 205);
|
||||
assert.deepEqual(first.messages.map(m => m.id), ids.slice(-50).reverse());
|
||||
assert.ok(first.messages.every(m => m.read === false && m.in_reply_to === null));
|
||||
assert.equal(first.next, ids[155]);
|
||||
const second = s.read('messages', { limit: 200, after: first.next });
|
||||
assert.deepEqual(second.messages.map(m => m.id), ids.slice(0,155).reverse());
|
||||
assert.equal(second.next, null);
|
||||
assert.equal(s.read('messages', {limit:200}).messages.length,200);
|
||||
assert.deepEqual(s.store.all('SELECT * FROM deliveries'), before);
|
||||
assert.deepEqual(call(s,s.human,'messages'), first);
|
||||
const got = call(s,s.human,'message.receive');
|
||||
call(s,s.human,'message.read',{id:got[0].id});
|
||||
const read = s.read('messages', {limit:200, after:ids[1]});
|
||||
assert.equal(read.messages[0].read,true); assert.equal(read.unread,204);
|
||||
const delivered = s.read('messages', {limit:200, after:ids[2]}).messages.find(m => m.id === got[1].id);
|
||||
assert.equal(delivered.read,false,'delivered without a read receipt remains unread');
|
||||
});
|
||||
test('messages refuses role callers, invalid paging and cursors outside this human feed', t => {
|
||||
const s=broker(t); send(s,'hello'); const other=send(s,'role','pm');
|
||||
for (const args of [{limit:0},{limit:201},{limit:1.5},{limit:'2'},{after:''},{after:3},{extra:true}])
|
||||
assert.throws(()=>s.read('messages',args),{code:'invalid-request'});
|
||||
for (const after of ['missing',other]) assert.throws(()=>s.read('messages',{after}),{code:'message-not-found'});
|
||||
assert.throws(()=>call(s,s.coder,'messages'),{code:'human-required'});
|
||||
const cap=s.b.bindReader({business:'demo'});
|
||||
assert.throws(()=>call(s,cap,'message.receive'),{code:'read-only'});
|
||||
});
|
||||
test('views messages passes only its paging arguments through', async()=>{
|
||||
const calls=[]; const v=views({call:async(...args)=>(calls.push(args),{messages:[],next:null})});
|
||||
await v.messages({limit:2,after:'m-1'}); await v.messages();
|
||||
assert.deepEqual(calls,[['messages',{limit:2,after:'m-1'}],['messages',{}]]);
|
||||
});
|
||||
|
||||
test('messages isolates businesses even with a valid foreign cursor', t => {
|
||||
const s=broker(t);
|
||||
// A second broker shares this test's store but binds a separate business.
|
||||
const otherBroker=new Broker({store:s.store,businesses:{other:{...BUSINESSES.demo,id:'other'}}});
|
||||
const agent=otherBroker.bindLaunch({business:'other',role:'coder',run:'other-run',harness:'pi'});
|
||||
otherBroker.request(agent,{verb:'role.claim'});
|
||||
const foreign=otherBroker.request(agent,{verb:'message.send',args:{to:'human',body:'foreign'}}).id;
|
||||
const own=send(s,'own');
|
||||
assert.deepEqual(s.read('messages').messages.map(m=>m.id),[own]);
|
||||
assert.throws(()=>s.read('messages',{after:foreign}),{code:'message-not-found'});
|
||||
const reader=otherBroker.bindReader({business:'other'});
|
||||
assert.deepEqual(otherBroker.request(reader,{verb:'messages'}).messages.map(m=>m.id),[foreign]);
|
||||
});
|
||||
Reference in New Issue
Block a user