bus, webui, cli: the Console shows the reply to a web send (#1575)
Row 81. The broker gains a `replies` read verb for human and reader
sessions: `{id}`, a message UUID, returns that business's messages whose
in_reply_to is the id, oldest first, at most 50, and writes no
deliveries row, so the reply still reaches the next `mosaic talk`. An
unknown id refuses message-not-found. The Console serves it through
BUS_VERBS with an id check, and the Sent receipt shows the replies to a
web send. The cli's talk timeout message and README now say the reply
may still come and the next `mosaic talk` prints it.
Dewey's round 2, candidate manifest 3ce7527f, base 1e5f5958, a rebase
after row 61. Approved by Darkwing (27783 round 1, 27821 round 2).
Sage's gate green 19:25:18Z to 19:30:39Z. Live at the next bus restart
(the broker verb) and Console restart (the view), bus first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
|
||||
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
|
||||
// Only the four verbs of the Q1 module (lead decision 56), row 57's
|
||||
// `decisions` and row 60's `credentials` reads are reachable as reads: no
|
||||
// read writes, not even seen (Q4). The one write is row 76's human
|
||||
// `message.send` (lead decision 95), built by the CLI only with --send. The
|
||||
// bus does the proof; a server started inside an agent run gets
|
||||
// `human-required` from the broker.
|
||||
// `decisions`, row 60's `credentials` and row 81's `replies` reads are
|
||||
// reachable as reads: no read writes, not even seen or delivered (Q4). The
|
||||
// one write is row 76's human `message.send` (lead decision 95), built by
|
||||
// the CLI only with --send. The bus does the proof; a server started inside
|
||||
// an agent run gets `human-required` from the broker.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
// can show what was read before, labelled as such.
|
||||
const last = new Map();
|
||||
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
||||
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||
async function fetchRead(verb, subject) {
|
||||
let res, body;
|
||||
try {
|
||||
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
|
||||
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store' });
|
||||
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);
|
||||
@@ -247,19 +248,27 @@
|
||||
// a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the
|
||||
// 10 s refresh redraws the form without losing it. Nothing resends: an unknown outcome stays
|
||||
// unknown and the draft stays put.
|
||||
let canSend = false, sendRoles = null, sending = false, receipt = null;
|
||||
// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each
|
||||
// render. That read marks nothing, so every reply still reaches the next `mosaic talk`.
|
||||
let canSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
|
||||
const REPLIES = 50; // the broker's cap
|
||||
function repliesHtml() {
|
||||
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic talk</code> in a terminal prints them.</p>';
|
||||
if (!replies.length) return '<p class="small muted">No reply yet. One shows here when the role answers, as the page refreshes.</p>';
|
||||
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
|
||||
}
|
||||
const draft = { to: '', body: '' };
|
||||
function composerHtml() {
|
||||
const roles = sendRoles ? [...new Set([...sendRoles, ...(draft.to && !sendRoles.includes(draft.to) ? [draft.to] : [])])] : null;
|
||||
const options = roles ? roles.map(r => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
|
||||
const r = receipt;
|
||||
const said = !r ? '' : r.kind === 'sent'
|
||||
? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>`
|
||||
? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>${repliesHtml()}`
|
||||
: r.kind === 'refused'
|
||||
? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
|
||||
: `<p class="receipt failed">Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.</p>`;
|
||||
return `<section class="panel s1-send" id="send-section" aria-labelledby="b-send"><h2 id="b-send">Message a role</h2>
|
||||
<p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. A reply comes back to you as a message, which this Console does not show yet. The next <code>mosaic talk</code> in a terminal prints it, after sending its own message.</p>
|
||||
<p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. Replies to it show under the receipt. The Console only shows them and never marks one delivered or read, so the next <code>mosaic talk</code> in a terminal still prints each one.</p>
|
||||
${roles === null ? '<p class="small warn">The held roles could not be read, so there is nothing to pick. Read again to retry.</p>' : !roles.length ? '<p class="small muted">No role is held right now, so there is nothing to pick.</p>' : ''}
|
||||
<form id="send-form" class="reply-form" novalidate><label for="send-to">To</label><select id="send-to" data-field="to"><option value="">Choose a held role</option>${options}</select>
|
||||
<label for="send-body">Message</label><textarea id="send-body" data-field="body" rows="4" placeholder="What should the role do?">${esc(draft.body)}</textarea>
|
||||
@@ -268,6 +277,13 @@
|
||||
async function composer(get) {
|
||||
if (!canSend) return '';
|
||||
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
|
||||
if (receipt?.kind === 'sent') {
|
||||
const r = receipt;
|
||||
let rows = null;
|
||||
try { rows = await get('replies', r.id); } catch {}
|
||||
// A send that finished during this read has its own receipt; leave it alone.
|
||||
if (receipt === r) replies = Array.isArray(rows) ? rows : null;
|
||||
}
|
||||
return composerHtml();
|
||||
}
|
||||
// Redraw only the form, keeping focus, after a send changes its state.
|
||||
@@ -772,7 +788,7 @@
|
||||
if (res?.type === 'opaqueredirect') {
|
||||
receipt = { kind: 'refused', to, code: 'sign-in', message: 'the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again' };
|
||||
} else if (res?.ok && SAFE.test(body?.id ?? '')) {
|
||||
receipt = { kind: 'sent', to, id: body.id, at: body.at };
|
||||
receipt = { kind: 'sent', to, id: body.id, at: body.at }; replies = [];
|
||||
if (draft.body === text) draft.body = ''; // keep text typed while the send was in flight
|
||||
} else if (res && !res.ok && ((res.status >= 400 && res.status < 500 && res.status !== 408) || body?.sent === false)) {
|
||||
receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message.replace(/^not sent: /, '') : 'refused' };
|
||||
|
||||
@@ -47,8 +47,9 @@ async function body(req, max = 4096) {
|
||||
}
|
||||
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
||||
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
||||
// (#1549) adds `credentials`: what the bus host loaded, metadata only.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'];
|
||||
// (#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 DECISION_LISTS = ['roles', 'recent'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let arg;
|
||||
@@ -60,6 +61,10 @@ async function busRead(res, bus, verb, search) {
|
||||
arg = new URLSearchParams(search).get('list');
|
||||
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
|
||||
}
|
||||
if (verb === 'replies') {
|
||||
arg = new URLSearchParams(search).get('id');
|
||||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
|
||||
}
|
||||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const rows = await bus[verb](arg);
|
||||
|
||||
Reference in New Issue
Block a user