diff --git a/packages/bus/README.md b/packages/bus/README.md index 690eb8e36..527768b1a 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -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`. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 5e211eefb..09a18633e 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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 ({ ...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 diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs index 27ca798fb..d5dc5a2c2 100644 --- a/packages/bus/src/views.mjs +++ b/packages/bus/src/views.mjs @@ -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), }); } diff --git a/packages/bus/tests/messages.test.mjs b/packages/bus/tests/messages.test.mjs new file mode 100644 index 000000000..477010553 --- /dev/null +++ b/packages/bus/tests/messages.test.mjs @@ -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]); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index 3fb537fda..eb1c6d9d1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -29,7 +29,8 @@ mosaic decide