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]);
|
||||
});
|
||||
+10
-3
@@ -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`.
|
||||
|
||||
+50
-14
@@ -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" });
|
||||
let sent;
|
||||
if (!receiveOnly) {
|
||||
sent = await call("message.send", { to, body, class: "REQUEST" });
|
||||
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
|
||||
if (wait === 0) return;
|
||||
if (!follow && wait === 0) return;
|
||||
}
|
||||
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
|
||||
const deadline = Date.now() + wait * 1000;
|
||||
for (;;) {
|
||||
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 (m.in_reply_to === sent?.id) answered = 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));
|
||||
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 });
|
||||
});
|
||||
}
|
||||
} 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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)}` }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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)}
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user