Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
807 lines
52 KiB
Diff
807 lines
52 KiB
Diff
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
||
index 690eb8e3..527768b1 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 5e211eef..09a18633 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<?) 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
|
||
diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs
|
||
index 27ca798f..d5dc5a2c 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 00000000..47701055
|
||
--- /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 3fb537fd..eb1c6d9d 100644
|
||
--- a/packages/cli/README.md
|
||
+++ b/packages/cli/README.md
|
||
@@ -29,7 +29,8 @@ mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||
mosaic tasks [--business <id>] [--json]
|
||
mosaic agents [--business <id>] [--json]
|
||
mosaic trail <task|decision> [--business <id>] [--json]
|
||
-mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
|
||
+mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||
+mosaic follow [--business <id>]
|
||
mosaic stop <run>
|
||
mosaic launches off|on [--business <id>]
|
||
mosaic launches list [--json]
|
||
@@ -48,7 +49,9 @@ mosaic launches list [--json]
|
||
these commands.** The tests use a stand-in transport.
|
||
- `inbox` lists the open decisions routed to the human, gated first, in
|
||
broker order. For a gated decision it prints what approving authorizes:
|
||
- the action, its target, and the one choice that authorizes it.
|
||
+ the action, its target, and the one choice that authorizes it. Text output
|
||
+ then lists all unread messages to the human, newest first, without marking
|
||
+ any delivered or read. `--json` keeps its existing decision-row array.
|
||
- `decide` takes the full id, or a unique prefix of at least 8 characters,
|
||
from the inbox. It prints the decision and whether the choice authorizes
|
||
or declines, then asks `[y/N]` on a terminal. Without a terminal it needs
|
||
@@ -62,7 +65,11 @@ mosaic launches list [--json]
|
||
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
|
||
REQUEST. No reply in time exits 1; the reply may still come, and the next
|
||
- `mosaic talk` prints it. `--wait 0` only sends.
|
||
+ `mosaic talk` prints it. `--wait 0` only sends. `talk --follow` keeps
|
||
+ printing and marking messages read until Ctrl-C, including messages with
|
||
+ no `in_reply_to`. `follow` runs that same loop without sending. `--wait`
|
||
+ and `--follow` together refuse. These commands consume the same human
|
||
+ inbox; run one receiving CLI at a time. Console only reads history.
|
||
- `launches off` and `on` are the broker's `launch.revoke` and
|
||
`launch.restore`. While off, every `role.launch` refuses with
|
||
`launch-revoked`; running sessions keep running. Stop them with `stop`.
|
||
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
||
index 6cf694a2..fcd7c013 100644
|
||
--- a/packages/cli/src/cli.mjs
|
||
+++ b/packages/cli/src/cli.mjs
|
||
@@ -29,7 +29,8 @@ export const USAGE = `usage:
|
||
mosaic tasks [--business <id>] [--json]
|
||
mosaic agents [--business <id>] [--json]
|
||
mosaic trail <task|decision> [--business <id>] [--json]
|
||
- mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
|
||
+ mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||
+ mosaic follow [--business <id>]
|
||
mosaic stop <run>
|
||
mosaic launches off|on [--business <id>]
|
||
mosaic launches list [--json]
|
||
@@ -170,7 +171,19 @@ async function human(verb, rest, io, deps) {
|
||
return print(await call("trail", { subject }), (rows) => formatTrail(subject, rows));
|
||
}
|
||
if (parsed.positional.length !== 0) throw usage(`${verb} takes no arguments`);
|
||
- if (verb === "inbox") return print(await call("inbox"), formatInbox);
|
||
+ if (verb === "inbox") {
|
||
+ const decisions = await call("inbox");
|
||
+ if (json) return print(decisions, formatInbox);
|
||
+ const messages = [];
|
||
+ let after;
|
||
+ do {
|
||
+ const page = await call("messages", { limit: 200, ...(after ? { after } : {}) });
|
||
+ messages.push(...page.messages.filter(m => !m.read));
|
||
+ after = page.next;
|
||
+ } while (after);
|
||
+ return print({ decisions, messages }, value => formatInbox(value.decisions) +
|
||
+ `\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
|
||
+ }
|
||
if (verb === "tasks") return print(await call("tasks"), formatTasks);
|
||
return print(await call("agents"), formatAgents);
|
||
}
|
||
@@ -184,11 +197,14 @@ function formatMessage(m) {
|
||
}
|
||
|
||
// `mosaic talk <role> <text>`: a REQUEST to the role, then the human's inbox
|
||
-// until the reply arrives or --wait runs out. Every message received on the
|
||
-// way is printed and marked read, so a late reply shows up on the next talk.
|
||
-async function talk(rest, io, deps) {
|
||
- const parsed = parse(rest, { values: ["--business", "--wait"] });
|
||
- if (parsed.positional.length !== 2) throw usage("talk takes a role and one quoted text");
|
||
+// until the reply arrives or --wait runs out. --follow keeps receiving after
|
||
+// that reply; follow starts the same receive loop without sending.
|
||
+async function talk(rest, io, deps, receiveOnly = false) {
|
||
+ const parsed = parse(rest, { flags: ["--follow"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] });
|
||
+ if (parsed.positional.length !== (receiveOnly ? 0 : 2)) throw usage(receiveOnly ? "follow takes no arguments" : "talk takes a role and one quoted text");
|
||
+ if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows");
|
||
+ const follow = receiveOnly || parsed.flags.has("--follow");
|
||
+ if (follow && parsed.values["--wait"] !== undefined) throw usage("--follow and --wait cannot be combined");
|
||
const wait = parsed.values["--wait"] === undefined ? TALK_WAIT_S : Number(parsed.values["--wait"]);
|
||
if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
|
||
refuseInsideAgent(io.env);
|
||
@@ -196,21 +212,40 @@ async function talk(rest, io, deps) {
|
||
const business = businessFor(parsed, system.dataRoot);
|
||
const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
|
||
const [to, body] = parsed.positional;
|
||
- const sent = await call("message.send", { to, body, class: "REQUEST" });
|
||
- io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
|
||
- if (wait === 0) return;
|
||
+ let sent;
|
||
+ if (!receiveOnly) {
|
||
+ sent = await call("message.send", { to, body, class: "REQUEST" });
|
||
+ io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
|
||
+ if (!follow && wait === 0) return;
|
||
+ }
|
||
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
|
||
const deadline = Date.now() + wait * 1000;
|
||
- for (;;) {
|
||
- let answered = false;
|
||
- for (const m of await call("message.receive")) {
|
||
- io.stdout.write(formatMessage(m));
|
||
- await call("message.read", { id: m.id });
|
||
- if (m.in_reply_to === sent.id) answered = true;
|
||
+ const controller = new AbortController();
|
||
+ const stop = () => controller.abort();
|
||
+ // Only continuous commands take ownership of Ctrl-C, and release it on every exit.
|
||
+ if (follow) process.on("SIGINT", stop);
|
||
+ deps.signal?.addEventListener("abort", stop, { once: true });
|
||
+ if (deps.signal?.aborted) stop();
|
||
+ try {
|
||
+ while (!controller.signal.aborted) {
|
||
+ let answered = false;
|
||
+ for (const m of await call("message.receive")) {
|
||
+ io.stdout.write(formatMessage(m));
|
||
+ await call("message.read", { id: m.id });
|
||
+ if (m.in_reply_to === sent?.id) answered = true;
|
||
+ }
|
||
+ if (!follow && answered) return;
|
||
+ if (!follow && 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);
|
||
+ if (controller.signal.aborted) break;
|
||
+ await new Promise(resolve => {
|
||
+ const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); };
|
||
+ const timer = setTimeout(wake, pollMs);
|
||
+ controller.signal.addEventListener("abort", wake, { once: true });
|
||
+ });
|
||
}
|
||
- 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 shows it`, 1);
|
||
- await new Promise((r) => setTimeout(r, pollMs));
|
||
+ } finally {
|
||
+ if (follow) process.removeListener("SIGINT", stop);
|
||
+ deps.signal?.removeEventListener("abort", stop);
|
||
}
|
||
}
|
||
|
||
@@ -336,6 +371,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||
const [verb, ...rest] = argv;
|
||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||
if (verb === "talk") return talk(rest, io, deps);
|
||
+ if (verb === "follow") return talk(rest, io, deps, true);
|
||
if (verb === "stop") return stop(rest, io, deps);
|
||
if (verb === "launches") return launches(rest, io, deps);
|
||
if (verb === "bus") return bus(rest, io, deps);
|
||
diff --git a/packages/cli/tests/cli.test.mjs b/packages/cli/tests/cli.test.mjs
|
||
index 7b5d09e8..57f8ea22 100644
|
||
--- a/packages/cli/tests/cli.test.mjs
|
||
+++ b/packages/cli/tests/cli.test.mjs
|
||
@@ -40,7 +40,7 @@ test("inbox lists only decisions routed to the human, with what approving author
|
||
assert.match(text, new RegExp(`decide: mosaic decide ${gated.id.slice(0, 8)} <option>`));
|
||
const json = await s.run(["inbox", "--business", "demo", "--json"]);
|
||
assert.equal(JSON.parse(json.io.out.text)[0].id, gated.id);
|
||
- assert.deepEqual(s.calls.map((c) => c.verb), ["inbox", "inbox"]);
|
||
+ assert.deepEqual(s.calls.map((c) => c.verb), ["inbox", "messages", "inbox"]);
|
||
});
|
||
|
||
test("decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow", async (t) => {
|
||
@@ -139,7 +139,7 @@ test("without --business a command uses the live host's business, and a stale ho
|
||
assert.equal(s.calls.length, 0);
|
||
write(startTimeOf(process.pid));
|
||
assert.equal((await s.run(["inbox"])).code, 0);
|
||
- assert.deepEqual(s.calls.map((c) => c.business), ["demo"]);
|
||
+ assert.deepEqual(s.calls.map((c) => c.business), ["demo", "demo"]);
|
||
});
|
||
|
||
test("every human command refuses inside an agent run before it touches the bus", async (t) => {
|
||
diff --git a/packages/cli/tests/follow.test.mjs b/packages/cli/tests/follow.test.mjs
|
||
new file mode 100644
|
||
index 00000000..10c8896d
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/follow.test.mjs
|
||
@@ -0,0 +1,86 @@
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import {main} from '../src/cli.mjs';
|
||
+import {broker,io,tmp} from './helpers.mjs';
|
||
+const call=(s,cap,verb,args={})=>s.b.request(cap,{verb,args});
|
||
+for(const follow of [false,true]) test(follow?'talk --follow continues after reply and prints a later unsolicited message':'follow prints an unsolicited message after plain talk returned',async t=>{
|
||
+ const s=broker(t), x=io(), controller=new AbortController();
|
||
+ let polls=0,sent;
|
||
+ const transport=business=>{const inner=s.transport(business);return async(verb,args)=>{
|
||
+ if(verb==='message.send') {const result=await inner(verb,args);sent=result.id;return result;}
|
||
+ if(verb==='message.receive'){
|
||
+ polls++;
|
||
+ if(polls===1 && sent)call(s,s.coder,'message.send',{to:'human',body:'direct answer',in_reply_to:sent});
|
||
+ if(polls===2)call(s,s.coder,'message.send',{to:'human',body:'later unsolicited',class:'INFO'});
|
||
+ if(polls===3)controller.abort();
|
||
+ }
|
||
+ return inner(verb,args);
|
||
+ }};
|
||
+ const deps={system:{dataRoot:tmp(t)},transport,talkPollMs:5,signal:controller.signal};
|
||
+ if(!follow){await main(['talk','coder','question','--business','demo'],x,deps); assert.match(x.out.text,/direct answer/);}
|
||
+ await main(follow?['talk','coder','question','--follow','--business','demo']:['follow','--business','demo'],x,deps);
|
||
+ assert.equal(polls,3); assert.match(x.out.text,/later unsolicited/);
|
||
+ assert.equal((x.out.text.match(/later unsolicited/g)||[]).length,1);
|
||
+ assert.deepEqual(call(s,s.human,'message.receive'),[]);
|
||
+ assert.equal(s.calls.filter(c=>c.verb==='message.send').length,1);
|
||
+ assert.equal(s.calls.filter(c=>c.verb==='message.read').length,2);
|
||
+});
|
||
+test('inbox lists all unread human messages under decisions, does not deliver or read them',async t=>{
|
||
+ const s=broker(t),x=io(),deps={system:{dataRoot:tmp(t)},transport:s.transport};
|
||
+ for(let i=0;i<205;i++)call(s,s.coder,'message.send',{to:'human',body:`unsolicited-${i}`,class:'INFO'});
|
||
+ const delivered=call(s,s.human,'message.receive');
|
||
+ call(s,s.human,'message.read',{id:delivered[0].id});
|
||
+ const before=s.store.all('SELECT * FROM deliveries');
|
||
+ await main(['inbox','--business','demo'],x,deps);
|
||
+ assert.match(x.out.text,/inbox:/);assert.match(x.out.text,/messages: 204 unread/);
|
||
+ assert.doesNotMatch(x.out.text,/unsolicited-0\n/);
|
||
+ assert.match(x.out.text,/unsolicited-1\n/);assert.match(x.out.text,/unsolicited-204\n/);
|
||
+ assert.deepEqual(s.store.all('SELECT * FROM deliveries'),before);
|
||
+ const json=io(); await main(['inbox','--business','demo','--json'],json,deps);
|
||
+ const value=JSON.parse(json.out.text);assert.deepEqual(value,[]);assert.equal(json.out.text,JSON.stringify(s.read("inbox"),null,2)+"\n");
|
||
+});
|
||
+test('follow refuses agent context and bad arguments',async t=>{
|
||
+ const s=broker(t),deps={system:{dataRoot:tmp(t)},transport:s.transport};
|
||
+ for(const argv of [['follow','extra'],['follow','--wait','1'],['talk','coder','hi','--follow','--wait','1']])await assert.rejects(main(argv,io(),deps),{exitCode:4});
|
||
+ await assert.rejects(main(['follow','--business','demo'],io({...io().env,CLAUDECODE:'1'}),deps),{exitCode:3});
|
||
+});
|
||
+
|
||
+test('follow Ctrl-C wakes the poll sleep and removes its handler; transport errors also clean up', async t => {
|
||
+ const s = broker(t), x = io(), before = process.listenerCount('SIGINT'), cleanup = new AbortController();
|
||
+ let received;
|
||
+ const ready = new Promise(resolve => { received = resolve; });
|
||
+ const deps = { system: { dataRoot: tmp(t) }, talkPollMs: 60000, signal: cleanup.signal,
|
||
+ transport: () => async verb => { assert.equal(verb, 'message.receive'); received(); return []; } };
|
||
+ const running = main(['follow', '--business', 'demo'], x, deps);
|
||
+ await ready;
|
||
+ // Let the command install its sleep's abort listener before emitting Ctrl-C.
|
||
+ await new Promise(resolve => setImmediate(resolve));
|
||
+ process.emit('SIGINT');
|
||
+ try {
|
||
+ await Promise.race([running, new Promise((_, reject) => {
|
||
+ const timer = setTimeout(() => reject(Error('Ctrl-C did not wake follow')), 500);
|
||
+ timer.unref();
|
||
+ })]);
|
||
+ } finally { cleanup.abort(); await running; }
|
||
+ assert.equal(process.listenerCount('SIGINT'), before);
|
||
+ await assert.rejects(main(['follow', '--business', 'demo'], io(), {
|
||
+ ...deps, signal: undefined, transport: () => async () => { throw Error('transport failed'); },
|
||
+ }), /transport failed/);
|
||
+ assert.equal(process.listenerCount('SIGINT'), before);
|
||
+});
|
||
+
|
||
+
|
||
+test('plain talk leaves the process SIGINT handlers unchanged',async t=>{
|
||
+ const s=broker(t),before=process.listeners('SIGINT'),x=io();let sent;
|
||
+ const deps={system:{dataRoot:tmp(t)},transport:business=>{const inner=s.transport(business);return async(verb,args)=>{
|
||
+ if(verb==='message.send'){const result=await inner(verb,args);sent=result.id;return result;}
|
||
+ if(verb==='message.receive'){
|
||
+ assert.deepEqual(process.listeners('SIGINT'),before);
|
||
+ call(s,s.coder,'message.send',{to:'human',body:'answer',in_reply_to:sent});
|
||
+ }
|
||
+ return inner(verb,args);
|
||
+ }}};
|
||
+ try {await main(['talk','coder','question','--business','demo'],x,deps);}
|
||
+ finally {for(const handler of process.listeners('SIGINT'))if(!before.includes(handler))process.removeListener('SIGINT',handler);}
|
||
+ assert.deepEqual(process.listeners('SIGINT'),before);
|
||
+});
|
||
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
||
index 6656f1ff..69ed45d2 100644
|
||
--- a/packages/webui/README.md
|
||
+++ b/packages/webui/README.md
|
||
@@ -276,7 +276,7 @@ delivery may be unknown: inspect the seat before sending again.
|
||
## Inbox, tasks, agents and trails (slice 1 S5, #1522)
|
||
|
||
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
|
||
-which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
|
||
+which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/messages`, `#/tasks`,
|
||
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>`,
|
||
`#/trail/decision/<id>` and `#/trail/message/<id>` (row 76), with `?kind=` filters on a trail. The design
|
||
note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||
@@ -466,7 +466,8 @@ session reader or transport is implemented here. The session reader is
|
||
|
||
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
|
||
`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent`,
|
||
-`/api/bus/credentials` and `/api/bus/replies?id=<message id>` are the bus
|
||
+`/api/bus/credentials`, `/api/bus/replies?id=<message id>` and
|
||
+`/api/bus/messages?limit=50&after=<message id>` are the bus
|
||
reads (`src/bus.mjs`). Any other method is 405. The subject and the id must
|
||
match the broker's identifier rule, and the list must be `roles` or
|
||
`recent`, else 400. `replies` (row 81, #1575) is the messages whose
|
||
@@ -633,3 +634,28 @@ say-so, not a test result. Keep #1507 open pending that ruling.
|
||
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
|
||
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
|
||
Seen and reply actions already taken belong to the board and are not rolled back.
|
||
+
|
||
+## Messages (row 94, #1588)
|
||
+
|
||
+The Messages page shows every message addressed to the human, newest first,
|
||
+including unsolicited messages and late replies. Each lists sender, class,
|
||
+time, read state, literal body and a trail link. It polls at the existing
|
||
+10-second cadence; pause and refresh work as on the other pages. Older
|
||
+messages and Newest messages links page through the history. The nav count
|
||
+is the whole feed's unread count, including messages beyond the visible
|
||
+page. If that count cannot be read, it is hidden; a bus refusal clears kept
|
||
+data as on the other pages. Reading this page marks nothing delivered or
|
||
+read. `mosaic follow` or `mosaic talk --follow` prints messages and marks
|
||
+them read in the terminal. The Console does not consume them.
|
||
+
|
||
+The messages HTTP read accepts only limit (1–200, default 50) and after
|
||
+(a message id cursor); malformed, duplicate or unknown query keys refuse
|
||
+400 invalid-request. It returns rows plus total, unread, next and the
|
||
+Console read time at. Older read routes retain their rows/at response.
|
||
+The broker owns business scoping and cursor authorization.
|
||
+
|
||
+`messages.test.mjs` exercises the route's paging/refusals and existing
|
||
+route response shapes, then the page against a temporary broker with
|
||
+a scheduled poll accelerated in the browser test. It proves literal
|
||
+content, late unsolicited arrival, pagination, full unread count and
|
||
+unchanged delivery receipts. No live bus data or service is used.
|
||
diff --git a/packages/webui/src/public/bus.css b/packages/webui/src/public/bus.css
|
||
index fc8507e3..b55cea52 100644
|
||
--- a/packages/webui/src/public/bus.css
|
||
+++ b/packages/webui/src/public/bus.css
|
||
@@ -39,3 +39,6 @@ table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;col
|
||
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
|
||
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
|
||
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
|
||
+
|
||
+/* Human message bodies keep their line breaks and wrap long literal text. */
|
||
+#message-list .s1-message { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
|
||
index e5a8e179..b9f9b2e2 100644
|
||
--- a/packages/webui/src/public/bus.js
|
||
+++ b/packages/webui/src/public/bus.js
|
||
@@ -34,12 +34,12 @@
|
||
async function fetchRead(verb, subject) {
|
||
if (out()) throw signedOut();
|
||
let res, body;
|
||
- try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
|
||
+ try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : verb === 'messages' ? 'after' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
|
||
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||
try { body = await res.json(); } 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);
|
||
- return { rows: body.rows, at: body.at };
|
||
+ return { ...body, rows: body.rows, at: body.at };
|
||
}
|
||
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
|
||
// error carries the module's own message, which the server has already redacted.
|
||
@@ -62,6 +62,7 @@
|
||
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
|
||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||
used.push(r.at);
|
||
+ if (verb === 'messages') { messageCount = r.unread; return r; }
|
||
return r.rows;
|
||
};
|
||
get.api = async path => {
|
||
@@ -235,9 +236,9 @@
|
||
const user = u => `user ${esc(u)}`;
|
||
|
||
// ---------------------------------------------------------------- views
|
||
- let inboxCount = null;
|
||
+ let inboxCount = null, messageCount = null;
|
||
function sections(route) {
|
||
- const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
|
||
+ const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
|
||
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
|
||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
||
@@ -312,6 +313,17 @@
|
||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||
}
|
||
+ async function messagesView(get, q) {
|
||
+ const after = q.get('after');
|
||
+ if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.');
|
||
+ const page = await get('messages', after === null ? undefined : after);
|
||
+ const body = page.rows.length ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
|
||
+ `<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${txt(m.body)}</p></li>`).join('')}</ul>`
|
||
+ : empty('No messages to you.', 'Messages appear here as the bus is read.');
|
||
+ return `${head('Messages', `${readNote()} Every message to you, newest first. Reading here marks nothing read.`)}
|
||
+ <p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
|
||
+ <nav aria-label="Message pages">${after ? '<a href="#/messages">Newest messages</a> · ' : ''}${page.next ? `<a data-older href="#/messages?after=${enc(page.next)}">Older messages</a>` : ''}</nav>`;
|
||
+ }
|
||
async function decisionView(get, id) {
|
||
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
|
||
if (!d) return notFound(`No decision with id ${txt(id)}.`);
|
||
@@ -621,7 +633,7 @@
|
||
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
|
||
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
|
||
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
|
||
- function forget() { last.clear(); inboxCount = null; }
|
||
+ function forget() { last.clear(); inboxCount = null; messageCount = null; }
|
||
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
|
||
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||
// Freshness line in the command bar for bus pages.
|
||
@@ -642,6 +654,7 @@
|
||
const [a, b, c] = parts, n = parts.length;
|
||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
|
||
+ if (a === 'messages' && n === 1) return messagesView(get, q);
|
||
if (a === 'tasks' && n === 1) return tasksView(get);
|
||
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
|
||
if (a === 'agents' && n === 1) return agentsView(get);
|
||
@@ -690,15 +703,17 @@
|
||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
|
||
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
|
||
- const [inbox, agents] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents')]);
|
||
- const failed = [inbox, agents].filter(s => s.status === 'rejected').map(s => s.reason);
|
||
+ const [inbox, agents, messages] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents'), fetchRead('messages')]);
|
||
+ const failed = [inbox, agents, messages].filter(s => s.status === 'rejected').map(s => s.reason);
|
||
if (failed.some(err => err.code === 'signed-out')) return;
|
||
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
|
||
if (agents.status === 'fulfilled') last.set('agents', agents.value);
|
||
+ if (messages.status === 'fulfilled') last.set('messages', messages.value);
|
||
const refused = failed.find(refusal);
|
||
if (refused) forget();
|
||
if (g !== gen) return;
|
||
if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
|
||
+ if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread;
|
||
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null));
|
||
sections(path); schedule();
|
||
return;
|
||
@@ -706,7 +721,12 @@
|
||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||
let html, state = 'fresh';
|
||
const live = reader(true);
|
||
- try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||
+ try {
|
||
+ if (path !== '/messages') {
|
||
+ try { await live('messages'); }
|
||
+ catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||
+ }
|
||
+ html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||
catch (err) {
|
||
if (err.code === 'signed-out') return;
|
||
const kept = reader(false);
|
||
@@ -771,7 +791,7 @@
|
||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||
// back where it was. It only navigates.
|
||
- const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
|
||
+ const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
|
||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||
function pkItems() {
|
||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
||
index 3d184d2f..4b385932 100644
|
||
--- a/packages/webui/src/serve.mjs
|
||
+++ b/packages/webui/src/serve.mjs
|
||
@@ -49,7 +49,7 @@ async function body(req, max = 4096) {
|
||
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
||
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
|
||
// (#1575) adds `replies`: the messages answering one message, for the send receipt.
|
||
-const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'];
|
||
+const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages'];
|
||
const DECISION_LISTS = ['roles', 'recent'];
|
||
async function busRead(res, bus, verb, search) {
|
||
let arg;
|
||
@@ -65,6 +65,25 @@ async function busRead(res, bus, verb, search) {
|
||
arg = new URLSearchParams(search).get('id');
|
||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
|
||
}
|
||
+ if (verb === 'messages') {
|
||
+ const query = new URLSearchParams(search), args = {};
|
||
+ const invalid = [...query.keys()].some(k => !['limit', 'after'].includes(k) || query.getAll(k).length !== 1);
|
||
+ if (invalid || (query.has('limit') && (!/^[1-9]\d*$/.test(query.get('limit')) || Number(query.get('limit')) > 200)) ||
|
||
+ (query.has('after') && !subjectOk(query.get('after'))))
|
||
+ return json(res, 400, { error: 'invalid-request', message: 'messages takes limit (1–200) and an after message id' });
|
||
+ if (query.has('limit')) args.limit = Number(query.get('limit'));
|
||
+ if (query.has('after')) args.after = query.get('after');
|
||
+ if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
+ try {
|
||
+ const page = await bus.messages(args);
|
||
+ if (!page || !Array.isArray(page.messages)) throw new BusReadError('invalid-response');
|
||
+ const { messages: rows, ...metadata } = page;
|
||
+ return json(res, 200, { rows, ...metadata, at: new Date().toISOString() });
|
||
+ } catch (err) {
|
||
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||
+ return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||
+ }
|
||
+ }
|
||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const rows = await bus[verb](arg);
|
||
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
|
||
index 5e28c9d6..e9b2a9c8 100644
|
||
--- a/packages/webui/tests/bus-browser.test.mjs
|
||
+++ b/packages/webui/tests/bus-browser.test.mjs
|
||
@@ -316,7 +316,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
|
||
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
|
||
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
|
||
assert.ok(requests.some(([, u]) => u === '/api/task-fields'));
|
||
- assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||
+ assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'messages', 'tasks', 'trail']);
|
||
|
||
// A Console with no bus configured says so, and its board still works.
|
||
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
|
||
diff --git a/packages/webui/tests/messages.test.mjs b/packages/webui/tests/messages.test.mjs
|
||
new file mode 100644
|
||
index 00000000..32984ece
|
||
--- /dev/null
|
||
+++ b/packages/webui/tests/messages.test.mjs
|
||
@@ -0,0 +1,108 @@
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import {broker} from '../../cli/tests/helpers.mjs';
|
||
+import {views} from '../../bus/src/views.mjs';
|
||
+import {startServer} from '../src/serve.mjs';
|
||
+import {close} from './fixture.mjs';
|
||
+import {browser} from './browser.mjs';
|
||
+import {BusReadError} from '../src/bus.mjs';
|
||
+
|
||
+function seed(t){
|
||
+ const s=broker(t),call=(verb,args={})=>s.b.request(s.reader,{verb,args});
|
||
+ const bus=views({call:async(...args)=>structuredClone(call(...args))});
|
||
+ const send=body=>s.b.request(s.coder,{verb:'message.send',args:{to:'human',body,class:'INFO'}}).id;
|
||
+ return {...s,bus,send};
|
||
+}
|
||
+test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{
|
||
+ const s=seed(t),a=s.send('first'),b=s.send('second');
|
||
+ const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});t.after(()=>close(web));
|
||
+ const url=`http://127.0.0.1:${web.address().port}/api/bus/`;
|
||
+ const get=async path=>{const res=await fetch(url+path);return {status:res.status,body:await res.json()}};
|
||
+ const page=await get('messages?limit=1');assert.equal(page.status,200);
|
||
+ assert.deepEqual(page.body.rows.map(m=>m.id),[b]); assert.equal(page.body.total,2);assert.equal(page.body.unread,2);assert.equal(page.body.next,b);
|
||
+ const next=await get(`messages?after=${b}&limit=1`);assert.deepEqual(next.body.rows.map(m=>m.id),[a]);assert.equal(next.body.next,null);
|
||
+ for(const query of ['limit=0','limit=201','limit=1.5','limit=no','limit=','after=','after=%3Cb%3E','limit=2&limit=3','after=x&after=y','extra=1']){
|
||
+ const res=await get('messages?'+query);assert.equal(res.status,400,query);assert.equal(res.body.error,'invalid-request');
|
||
+ }
|
||
+ for(const verb of ['inbox','tasks','agents','replies?id='+a,'decisions?list=recent','credentials','trail?subject='+a]){
|
||
+ const res=await get(verb);assert.equal(res.status,200,verb);assert.deepEqual(Object.keys(res.body).sort(),['at','rows']);
|
||
+ const [name,query='']=verb.split('?'),params=new URLSearchParams(query);
|
||
+ const args=Object.fromEntries(params);
|
||
+ assert.deepEqual(res.body.rows,structuredClone(s.b.request(s.reader,{verb:name,args})),verb);
|
||
+ assert.match(res.body.at,/^\d{4}-.*Z$/);
|
||
+ }
|
||
+});
|
||
+test('Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver', {timeout:45000},async t=>{
|
||
+ const s=seed(t),early=s.send('early');
|
||
+ const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});
|
||
+ const b=await browser({machine:false});
|
||
+ const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out')})()`);
|
||
+ try{
|
||
+ // Reach a real scheduled poll without making a test wait ten seconds.
|
||
+ await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'});
|
||
+ await b.navigate(`http://127.0.0.1:${web.address().port}/#/messages`);
|
||
+ await wait('document.querySelector("#message-list")');
|
||
+ const late=s.send('<img src=x onerror="window.injected=1"> late unsolicited');
|
||
+ const before=s.store.all('SELECT * FROM deliveries');
|
||
+ await wait('document.querySelector("#message-list")?.textContent.includes("late unsolicited")');
|
||
+ assert.deepEqual(s.store.all('SELECT * FROM deliveries'),before);
|
||
+ const items=await b.evaluate('[...document.querySelectorAll("#message-list li")].map(e=>({text:e.textContent,href:e.querySelector("a").getAttribute("href")}))');
|
||
+ assert.match(items[0].text,/coder.*INFO.*Unread/s);assert.match(await b.evaluate('document.querySelector("#message-list time").getAttribute("datetime")'), /^\d{4}-.*Z$/);
|
||
+ await b.viewport(400,900);
|
||
+ assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||
+ if(process.env.ROCKO_MESSAGES_SHOT) await b.screenshot(process.env.ROCKO_MESSAGES_SHOT);
|
||
+ assert.equal(items[0].href,`#/trail/message/${late}`);assert.equal(items[1].href,`#/trail/message/${early}`);
|
||
+ assert.equal(await b.evaluate('!!window.injected || !!document.querySelector("#message-list img")'),false);
|
||
+ for(let i=0;i<52;i++)s.send(`backlog ${i}`);
|
||
+ await wait(`document.querySelector('#sections a[href="#/messages"]')?.textContent.includes("54")`);
|
||
+ await wait('document.querySelectorAll("#message-list li").length===50');
|
||
+ assert.ok(await b.evaluate('!!document.querySelector("#bus-view a[data-older]")'));
|
||
+ await b.evaluate('document.querySelector("#bus-view a[data-older]").click(),true');
|
||
+ await wait('document.querySelectorAll("#message-list li").length===4');
|
||
+ assert.match(await b.evaluate('document.querySelector("#message-list").textContent'),/late unsolicited/);
|
||
+ const got=s.b.request(s.human,{verb:'message.receive'});
|
||
+ for(const m of got)s.b.request(s.human,{verb:'message.read',args:{id:m.id}});
|
||
+ await wait('document.querySelector("#message-list")?.textContent.includes("Read") && !document.querySelector("#message-list")?.textContent.includes("Unread")');
|
||
+ assert.equal(await b.evaluate(`document.querySelector('#sections a[href="#/messages"] .count').textContent`),'0');
|
||
+ }finally{await b.close();await close(web)}
|
||
+});
|
||
+
|
||
+
|
||
+test('Messages nav count polls on Tasks and Board and clears on a Board refusal; messages 403 refuses local pages', {timeout:45000}, async t=>{
|
||
+ const s=seed(t);s.send('one');s.send('two');
|
||
+ let messagesFailure=null;
|
||
+ const bus={...s.bus,messages:async args=>{if(messagesFailure)throw new BusReadError(messagesFailure);return s.bus.messages(args);}};
|
||
+ const at=()=>new Date().toISOString(), localReads=[];
|
||
+ const reads={
|
||
+ queue:async()=>{localReads.push('queue');return {rows:[],notes:[],at:at()};},
|
||
+ business:async()=>{localReads.push('business');return {business:{id:'demo',human:'jason',arbiters:{},projects:[],vars:{},instances:[],actions:[],credentials:[]},at:at()};},
|
||
+ settings:async()=>{localReads.push('settings');return {release:'0.0.1',business:'demo',notifier:{binding:null},at:at()};},
|
||
+ };
|
||
+ const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads});
|
||
+ const b=await browser({machine:false});
|
||
+ const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out: '+${JSON.stringify(expr)})})()`);
|
||
+ const badge=`document.querySelector('#sections a[href="#/messages"] .count')`;
|
||
+ try{
|
||
+ await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'});
|
||
+ await b.navigate(`http://127.0.0.1:${web.address().port}/#/tasks`);
|
||
+ await wait(`document.querySelector('#bus-view h1')?.textContent==='Tasks' && ${badge}?.textContent==='2'`);
|
||
+ s.send('three');await wait(`${badge}?.textContent==='3'`);
|
||
+ await b.evaluate('location.hash="#/",true');
|
||
+ await wait(`document.querySelector('#bus-view').hidden && document.querySelector('#held')?.textContent.includes('coder') && ${badge}?.textContent==='3'`);
|
||
+ s.send('four');await wait(`${badge}?.textContent==='4'`);
|
||
+ messagesFailure='human-required';
|
||
+ await wait(`!${badge} && document.querySelector('#held')?.textContent.includes('refused')`);
|
||
+ messagesFailure=null;await wait(`${badge}?.textContent==='4'`);
|
||
+ for(const [path,title] of [['queue','Queue'],['business','Business demo'],['settings','Settings']]){
|
||
+ await b.evaluate(`location.hash=${JSON.stringify('#/'+path)},true`);
|
||
+ await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`);
|
||
+ const before=localReads.length;
|
||
+ messagesFailure='human-required';
|
||
+ await wait(`!!document.querySelector('#bus-view .banner.ref') && !${badge}`);
|
||
+ assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),/Bus refused the read/);
|
||
+ assert.equal(localReads.length,before,'a refused messages pre-read prevents the local read');
|
||
+ messagesFailure=null;
|
||
+ await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`);
|
||
+ }
|
||
+ }finally{await b.close();await close(web)}
|
||
+});
|
||
diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
|
||
index 2e372903..3aa02734 100644
|
||
--- a/packages/webui/tests/shell.test.mjs
|
||
+++ b/packages/webui/tests/shell.test.mjs
|
||
@@ -263,7 +263,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
|
||
}
|
||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
|
||
- assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 7);
|
||
+ assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 8);
|
||
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
|
||
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
|
||
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
|
||
@@ -459,7 +459,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
||
}
|
||
|
||
// After a refusal the palette lists only the views: no kept decision or task.
|
||
- const views = ['#/', '#/inbox', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
|
||
+ const views = ['#/', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
|
||
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
|
||
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
|
||
diff --git a/packages/webui/tests/views.test.mjs b/packages/webui/tests/views.test.mjs
|
||
index 5877bdca..80ffceac 100644
|
||
--- a/packages/webui/tests/views.test.mjs
|
||
+++ b/packages/webui/tests/views.test.mjs
|
||
@@ -56,7 +56,7 @@ async function page(b, base) {
|
||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
|
||
await b.viewport(400, 900);
|
||
await b.navigate(base);
|
||
- await wait('document.querySelectorAll("#sections a").length===7');
|
||
+ await wait('document.querySelectorAll("#sections a").length===8');
|
||
return { wait, text, count, flat, refresh, go };
|
||
}
|
||
|