diff --git a/agents/morgana/work/queue-225/BUILD.md b/agents/morgana/work/queue-225/BUILD.md index 7b8e27eb9..a093abb40 100644 --- a/agents/morgana/work/queue-225/BUILD.md +++ b/agents/morgana/work/queue-225/BUILD.md @@ -47,7 +47,9 @@ If 217 uses other names, I change mine to theirs before either of us pushes (sag - Verb: `library.revert`. A human verb (design M4): from an agent session it refuses `human-required`, like `library.status` (`broker.mjs:5133`). -- Args: `{ "project": , "commit": <40 lowercase hex> }`. Both are required, and no other key +- Args: `{ "project": , "commit": <40 lowercase hex> }`. The design writes + `library.revert {commit}`. `project` is added to match the other library verbs, and like theirs it is the + key of `business.projects` (`mirror.mjs:31`), which is the chat project's `repo` link, not its UUID. Both are required, and no other key is allowed (`invalid-request`), as the other library verbs check with `keys(args, ...)`. - Result: `{ "project", "path", "reverted": , "commit": }`. - Refusal codes the Console shows by name: @@ -120,3 +122,56 @@ deviations, both recorded here: 220 changes one line each in the webui tests discord.test.mjs, fixture.mjs, history-fixture.mjs, relaunch.test.mjs and return-flow.test.mjs. Its other paths are in control-board, conversation, discord and business. 225 doesn't touch those five files or those packages. Its webui tests are new files. + +## Handoff to Fethry (mos-1501, Q245) + +The row moved to Fethry (Codex, fethry/row225). Everything below is **untested**: no suite or single file +has run on it, since a run needs Sage's CLEAR. Each changed file passes `node --check`. + +Done in the handoff commit: + +- `render.mjs` `renderReply(body, {check, changed, name})`; index export; README section; + `render.test.mjs` rewritten. `renderItems` is gone from every package. +- `serve.mjs`: `/render.mjs` static entry (owner and user file maps), `GET /api/library/status` + (one `project` query, answer must name that project), `POST /api/library/revert` (save's boundary, + strict `{project, commit}`, refusal shows the code, lost or unusable answer is `outcome-unknown`, + never retried). Both are nulled for a granted user, and `userRoute` keeps them owner-only. The tester + listener refuses `libraryRevert`. +- `bus.mjs`: `library-revert-not-agent: 409`. That re-pins `packages/webui/src/bus.mjs` in + `packages/bus/tests/channel-proof.test.mjs`, with a comment line, as row 254 did. It's a third deviation, + in another package's test. +- `console-bus.mjs` and `cli.mjs`: `libraryRevert`, beside `libraryStatus`. +- `chat.js`: dynamic `import('/render.mjs')` (CSP is `script-src 'self'`); body text until it loads. + Cites with a scope badge, the agent-written label, "changed since" from `file`/`last`, links only for + https with `rel="noopener noreferrer"`. A notice gets a Revert button behind `confirm()`. The library + status line goes under the thread heading. +- `chat.css`: cite, badge, notice and status styles. +- New tests: `packages/webui/tests/library-routes.test.mjs` and + `packages/webui/tests/chat-replies-browser.test.mjs`. 220's five files are untouched. + +Decisions made in the build: + +- The library name is the chat project's `repo`. A project with no repo reads no status and its + Revert does nothing. +- These codes hide the status line: `project-not-declared`, `library-mapping-invalid`, + `project-not-configured`, `not-configured` and `owner-only`. + - Otherwise every code-repo thread, and every granted user, would show "unavailable". + - Cost: a broken library mapping also hides the line. `library.status` from the CLI still shows it. +- "Age" is the time since `lastFetch` ("fetched 5 min ago"), not the served commit's age, which the + status doesn't carry. +- The limits fields are assumed to be `replyLimits {requireCitation, citationsPerReply}` and + `replyLimitsFallback [keys]`, and are shown only when present. **Not agreed with 217.** Confirm with + Beakley and rename. +- A notice body's shape is all the renderer checks. An agent in a business without a library could + send a body that looks like a notice. The page would show it, but `library.revert` still refuses it. + +Left: + +1. Get CLEAR and run the webui and library suites, `channel-proof.test.mjs`, and the two new files whole. + The browser test's selectors and waits have never run. +2. Agree the limits field names with 217 (Beakley). +3. Granted-user R6 G5 (`granted-user.test.mjs:252`) lists owner verbs by hand. Add `library.revert` + once 288 lands. +4. BUILD.md: the mutant list and the mos-1275 timeout plan. +5. The manifest names the three deviations: chat.js and chat.css in place of bus.js, the console-bus and + cli lines, and the channel-proof re-pin. diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs index ba0179832..435d8fa2d 100644 --- a/packages/bus/tests/channel-proof.test.mjs +++ b/packages/bus/tests/channel-proof.test.mjs @@ -9,6 +9,8 @@ // Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five // MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall, // which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged. +// Row 225 (C2-10b) re-pins webui bus.mjs for one STATUS entry, library-revert-not-agent: 409; no proof +// or transport change. import test from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; @@ -29,7 +31,7 @@ const baselines = { // Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and // organization-tracker-bot-taken 409; no proof or transport change. // Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged. - "packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c", + "packages/webui/src/bus.mjs": "9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658", }; const repoFile = path => new URL("../../../" + path, import.meta.url); diff --git a/packages/library/README.md b/packages/library/README.md index b7d0f579c..5a65241db 100644 --- a/packages/library/README.md +++ b/packages/library/README.md @@ -104,21 +104,28 @@ in an extension other than `.md`, in any case, embeds an attachment, and it is n a dot and up to 8 letters and digits, at least one a letter. Any other unresolved embed is R4, and so is an unresolved front matter value written as an embed (C2-01b N3, sage-1900). -## renderItems +## renderReply -`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show. -It is pure. Item kinds are `fact`, `finding`, `correction` (with `from` and `to`), `question` and -`idea`. Each cite is a path with commit, line range and quote, or a url with a quote. +`renderReply(body, { check, changed, name })` turns a stored reply into what both channels show. It +replaces C2-01's items renderer and is pure. A body is free text, or the design 5.2 object +`{ "text", "citations" }` with library cites `{ path, commit, lines, quote, as? }` and web cites +`{ url, quote }`. Any other body, a JSON object with another key or a bad cite included, renders as +free text: the whole body, with no citations. + +`check` is the stored check result (C2-06), `{ citations: [{ scope, agent }] }` in body order. A path +cite's badge (`canon`, `playground` or `supporting`) and its agent-written label come only from it. A +url cite is always `web`, and is a link only when its scheme is `https`. `changed({ path, commit }, i)` +says whether the file's last commit is newer than the cited one. `plain` is the rendering as text: ``` -1. Correction: Chapter 1 says grey. - From: "green eyes" - To: "grey eyes" - [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes" (changed since) +Mara's eyes are grey in chapter 3. + [canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes" + [supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since) + [web] https://example.invalid/tides "spring tides" ``` -A body of `{ "items": [], "notice": ... }` gives `No cited answer`, `No answer` or `The turn failed`. -Any other shape gives the fixed line `This reply could not be shown.` and none of the body's text. +The broker's write notice, `{ "notice": "library-write", op, path, commit }`, gives the line +` wrote @` or ` deleted @`. ## Survey diff --git a/packages/library/src/index.mjs b/packages/library/src/index.mjs index e038309d6..a5709cc71 100644 --- a/packages/library/src/index.mjs +++ b/packages/library/src/index.mjs @@ -4,7 +4,7 @@ export { checkMapping, TYPES } from './mapping.mjs'; export { classify } from './scope.mjs'; export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs'; export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs'; -export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs'; +export { renderReply, SCOPES, NOTICE_OPS } from './render.mjs'; export { survey, formatSurvey, surveyMain } from './survey.mjs'; export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs'; export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs'; diff --git a/packages/library/src/render.mjs b/packages/library/src/render.mjs index 398ee0090..36759e59a 100644 --- a/packages/library/src/render.mjs +++ b/packages/library/src/render.mjs @@ -1,59 +1,72 @@ -// renderItems(body): how a stored cited reply looks (design 7.4), shared by the Console chat (C2-10b) -// and the Discord relay (C2-18). Pure: no git, no clock. The caller passes what it knows about cites. -// The guard (C2-06) checks limits before a reply is stored. This only refuses to show a body that -// doesn't have the reply shape, and then it shows a fixed line, never the body's text. -export const KINDS = Object.freeze({ fact: 'Fact', finding: 'Finding', correction: 'Correction', question: 'Question', idea: 'Idea' }); -export const NOTICES = Object.freeze({ 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' }); -export const UNREADABLE = 'This reply could not be shown.'; +// renderReply(body): how a stored reply looks (design 7.4), shared by the Console chat (C2-10b) and the +// Discord relay (C2-18). It replaces C2-01's items renderer. Pure: no git, no clock, no DOM. The caller +// passes what only the bus knows: the stored check result (C2-06) and whether a cited file changed. +// The guard checks a reply before it is stored. This only decides how a body reads; it trusts nothing +// in the body for a scope or the agent-written label. +export const SCOPES = Object.freeze(['canon', 'playground', 'supporting', 'web']); +export const NOTICE_OPS = Object.freeze({ write: 'wrote', delete: 'deleted' }); const SHA = /^[0-9a-f]{40}$/; const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value); const line = n => Number.isSafeInteger(n) && n > 0; -function cite(value, { scopeOf, changed }) { - if (!plain(value) || typeof value.quote !== 'string') return null; - if (Object.hasOwn(value, 'url')) { - if (typeof value.url !== 'string' || Object.hasOwn(value, 'path')) return null; - return { badge: 'web', url: value.url, ref: value.url, quote: value.quote, changed: false }; - } - const { path, commit, lines } = value; - if (typeof path !== 'string' || !SHA.test(commit ?? '') || !Array.isArray(lines) || lines.length !== 2 - || !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]) return null; - const badge = scopeOf?.(path); - const ref = `${path}:${lines[0] === lines[1] ? lines[0] : `${lines[0]}-${lines[1]}`} @${commit.slice(0, 7)}`; - return { badge: badge === 'canon' || badge === 'playground' ? badge : null, path, commit, lines: [...lines], ref, - quote: value.quote, changed: changed?.({ path, commit }) === true }; -} +const only = (value, keys) => Object.keys(value).every(k => keys.includes(k)); const quoted = text => JSON.stringify(text); +// webby-0041 N5: only an https url is ever a link. Anything else, javascript: and http: included, is text. +function https(url) { + try { return new URL(url).protocol === 'https:' ? url : null; } catch { return null; } +} -// body: the stored message body (a JSON string or the parsed object). -// options.scopeOf(path) -> 'canon' | 'playground' | undefined, for the scope badge. -// options.changed({path, commit}) -> true when the file's last commit is newer than the cited one. -// Returns { kind: 'items', items, text } | { kind: 'notice', notice, text } | { kind: 'unreadable', text }. -export function renderItems(body, options = {}) { - const unreadable = { kind: 'unreadable', text: UNREADABLE }; +// The design 5.2 cite shapes. null when the value isn't one, and then the body is not a cited reply. +function cite(value) { + if (!plain(value) || typeof value.quote !== 'string') return null; + if (Object.hasOwn(value, 'url')) return typeof value.url === 'string' && only(value, ['url', 'quote']) ? { url: value.url, quote: value.quote } : null; + const { path, commit, lines } = value; + if (!only(value, ['path', 'commit', 'lines', 'quote', 'as']) || typeof path !== 'string' || !SHA.test(commit ?? '') + || !Array.isArray(lines) || lines.length !== 2 || !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0] + || (Object.hasOwn(value, 'as') && value.as !== 'canon')) return null; + return { path, commit, lines: [...lines], quote: value.quote, canon: value.as === 'canon' }; +} + +// check: the stored entry for this cite, { scope, agent }, or undefined. A path cite takes its badge only +// from it; a url cite is web by its shape. agent is the Mosaic-Agent trailer value, never the body's. +function shown(c, check, changed, i) { + if (c.url !== undefined) return { badge: 'web', url: c.url, link: https(c.url), ref: c.url, quote: c.quote, agent: null, changed: false }; + const scope = plain(check) && SCOPES.includes(check.scope) && check.scope !== 'web' ? check.scope : null; + const agent = plain(check) && typeof check.agent === 'string' && check.agent !== '' ? check.agent : null; + const ref = `${c.path}:${c.lines[0] === c.lines[1] ? c.lines[0] : `${c.lines[0]}-${c.lines[1]}`} @${c.commit.slice(0, 7)}`; + return { badge: scope, path: c.path, commit: c.commit, lines: c.lines, ref, quote: c.quote, canon: c.canon, agent, + changed: changed?.({ path: c.path, commit: c.commit }, i) === true }; +} + +// The broker's write notice (design, writes): {"notice": "library-write", op, path, commit}, no agent text. +function notice(value, name) { + if (!plain(value) || value.notice !== 'library-write' || !only(value, ['notice', 'op', 'path', 'commit']) + || !Object.hasOwn(NOTICE_OPS, value.op) || typeof value.path !== 'string' || !SHA.test(value.commit ?? '')) return null; + const who = typeof name === 'string' && name !== '' ? name : 'An agent'; + return { kind: 'notice', op: value.op, path: value.path, commit: value.commit, + text: `${who} ${NOTICE_OPS[value.op]} ${value.path} @${value.commit.slice(0, 7)}` }; +} + +// body: the stored message body, a string (or an already parsed object). +// options.check: the stored check result, { citations: [{ scope, agent }] } in body order, or null. +// options.changed({path, commit}, index) -> true when the file's last commit is newer than the cited one. +// options.name: the sender's name for a notice line. +// Returns { kind: 'text', text, plain } | { kind: 'reply', text, citations, plain } | { kind: 'notice', op, path, commit, text, plain }. +// plain is the whole rendering as text, for a channel that shows no markup. +export function renderReply(body, options = {}) { + const raw = typeof body === 'string' ? body : JSON.stringify(body) ?? String(body); let value = body; - if (typeof body === 'string') { try { value = JSON.parse(body); } catch { return unreadable; } } - if (!plain(value) || !Array.isArray(value.items)) return unreadable; - const keys = Object.keys(value); - if (value.items.length === 0) { - if (keys.length !== 2 || !Object.hasOwn(NOTICES, value.notice)) return unreadable; - return { kind: 'notice', notice: value.notice, text: NOTICES[value.notice] }; - } - if (keys.length !== 1) return unreadable; - const items = []; - for (const item of value.items) { - if (!plain(item) || !Object.hasOwn(KINDS, item.kind) || typeof item.text !== 'string' || !Array.isArray(item.cites)) return unreadable; - const correction = item.kind === 'correction'; - if (correction && (typeof item.from !== 'string' || typeof item.to !== 'string')) return unreadable; - const cites = item.cites.map(c => cite(c, options)); - if (cites.some(c => c === null)) return unreadable; - items.push({ kind: item.kind, label: KINDS[item.kind], text: item.text, - ...(correction ? { from: item.from, to: item.to } : {}), cites }); - } - const text = items.map((item, i) => [ - `${i + 1}. ${item.label}: ${item.text}`, - ...(item.kind === 'correction' ? [` From: ${quoted(item.from)}`, ` To: ${quoted(item.to)}`] : []), - ...item.cites.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.changed ? ' (changed since)' : ''}`), - ].join('\n')).join('\n'); - return { kind: 'items', items, text }; + if (typeof body === 'string') { try { value = JSON.parse(body); } catch { value = undefined; } } + const n = notice(value, options.name); + if (n) return { ...n, plain: n.text }; + // Design 5.2: a JSON object must be {text, citations} and nothing else. Any other body is a reply whose + // text is the whole body and which carries no citation. + const free = { kind: 'text', text: raw, plain: raw }; + if (!plain(value) || typeof value.text !== 'string' || !Array.isArray(value.citations) || !only(value, ['text', 'citations'])) return free; + const cites = value.citations.map(cite); + if (cites.some(c => c === null)) return free; + const checks = plain(options.check) && Array.isArray(options.check.citations) ? options.check.citations : []; + const citations = cites.map((c, i) => shown(c, checks[i], options.changed, i)); + const lines = citations.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.agent ? ` (agent-written: ${c.agent})` : ''}${c.changed ? ' (changed since)' : ''}`); + return { kind: 'reply', text: value.text, citations, plain: [value.text, ...lines].join('\n') }; } diff --git a/packages/library/tests/render.test.mjs b/packages/library/tests/render.test.mjs index b07e26d25..9c861bfe9 100644 --- a/packages/library/tests/render.test.mjs +++ b/packages/library/tests/render.test.mjs @@ -1,80 +1,94 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { renderItems, UNREADABLE } from '../src/index.mjs'; +import { renderReply } from '../src/index.mjs'; +// Row 225 (C2-10b): renderReply replaces renderItems (design 7.4, the reply schema of 5.2). const A = 'a'.repeat(40), B = 'b'.repeat(40); -const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote }); -const options = { - scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined, - changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A, -}; -const render = items => renderItems(JSON.stringify({ items }), options); +const cite = (path, lines, quote, commit = A, extra = {}) => ({ path, commit, lines, quote, ...extra }); +const web = (url, quote = 'spring tides') => ({ url, quote }); +const reply = (text, citations) => JSON.stringify({ text, citations }); +const CITED = reply("Mara's eyes are grey in chapter 3.", [ + cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', A, { as: 'canon' }), + cite('Book One/Playground/What If.md', [5, 6], 'a storm', B), + cite('Notes/Eyes.md', [9, 10], 'green eyes'), + web('https://example.invalid/tides'), +]); +const CHECK = { citations: [{ scope: 'canon', agent: null }, { scope: 'playground', agent: null }, + { scope: 'supporting', agent: 'acme/scribe' }, { scope: 'web', agent: null }] }; +const changed = ({ path, commit }) => path === 'Notes/Eyes.md' && commit === A; -// One snapshot per item kind: the plain text both channels show. -const SNAPSHOTS = { - fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }], - finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'), - cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }], - correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes', - cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }], - question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }], - idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B), - { url: 'https://example.invalid/tides', quote: 'spring tides' }] }], -}; -const EXPECTED = { - fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"', - finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n' - + ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)', - correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n' - + ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"', - question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"', - idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n' - + ' [web] https://example.invalid/tides "spring tides"', -}; - -for (const kind of Object.keys(SNAPSHOTS)) { - test(`renderItems snapshot: ${kind}`, () => { - const out = render(SNAPSHOTS[kind]); - assert.equal(out.kind, 'items'); - assert.equal(out.text, EXPECTED[kind]); - assert.equal(out.items[0].kind, kind); - }); -} - -test('renderItems numbers several items and takes a parsed body as well as a string', () => { - const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question]; - const out = renderItems({ items }, options); - assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`); - assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4], - ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false }); +test('free text renders as itself with no citations', () => { + for (const body of ['Grey, in chapter 3.', '', '"a JSON string"', '[1, 2]', '42', 'null']) + assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }); }); -test('without options a path cite has no badge and is never marked changed', () => { - assert.equal(renderItems({ items: SNAPSHOTS.finding }).text, - '1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n' - + ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"'); +test('a cited reply renders its text, then each cite with the badge from the check result', () => { + const out = renderReply(CITED, { check: CHECK, changed }); + assert.equal(out.kind, 'reply'); + assert.equal(out.text, "Mara's eyes are grey in chapter 3."); + assert.deepEqual(out.citations.map(c => c.badge), ['canon', 'playground', 'supporting', 'web']); + assert.equal(out.plain, "Mara's eyes are grey in chapter 3.\n" + + ' [canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"\n' + + ' [playground] Book One/Playground/What If.md:5-6 @bbbbbbb "a storm"\n' + + ' [supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)\n' + + ' [web] https://example.invalid/tides "spring tides"'); + assert.deepEqual(out.citations[0], { badge: 'canon', path: 'Book One/Chapters/Chapter 03.md', commit: A, lines: [41, 41], + ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', quote: 'her grey eyes', canon: true, agent: null, changed: false }); + assert.deepEqual(out.citations[3], { badge: 'web', url: 'https://example.invalid/tides', link: 'https://example.invalid/tides', + ref: 'https://example.invalid/tides', quote: 'spring tides', agent: null, changed: false }); }); -const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' }; -for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) { - test(`renderItems snapshot: notice ${notice}`, () => { - assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text }); - }); -} +test('a parsed body renders as its string does', () => { + assert.deepEqual(renderReply(JSON.parse(CITED), { check: CHECK, changed }), renderReply(CITED, { check: CHECK, changed })); +}); -test('a body without the reply shape shows a fixed line and none of its text', () => { - const secret = 'free prose the guard would refuse'; - const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }), - JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }), - JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }), - null, 42, '[]']; - for (const body of bodies) { - const out = renderItems(body, options); - assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body)); - assert.ok(!JSON.stringify(out).includes(secret)); +test('without a check result a path cite has no badge, no label and is never changed', () => { + const out = renderReply(CITED); + assert.deepEqual(out.citations.map(c => [c.badge, c.agent, c.changed]), + [[null, null, false], [null, null, false], [null, null, false], ['web', null, false]]); +}); + +test('the label and the badge come from the check result only, never from a bad entry', () => { + const bad = { citations: [{ scope: 'web', agent: '' }, { scope: 'story', agent: 7 }, null, { scope: 'canon', agent: 'x/y' }] }; + const out = renderReply(CITED, { check: bad }); + // A path cite can't be web; a url cite is web by its shape whatever the check says, and never agent-written. + assert.deepEqual(out.citations.map(c => [c.badge, c.agent]), [[null, null], [null, null], [null, null], ['web', null]]); +}); + +test('a web cite is a link only for https', () => { + const urls = { 'https://example.invalid/a': 'https://example.invalid/a', 'http://example.invalid/a': null, + 'javascript:alert(1)': null, 'JAVASCRIPT:alert(1)': null, 'data:text/html,x': null, 'not a url': null, '//example.invalid': null }; + for (const [url, link] of Object.entries(urls)) { + const out = renderReply(reply('t', [web(url)])); + assert.equal(out.kind, 'reply', url); + assert.equal(out.citations[0].link, link, url); + assert.equal(out.citations[0].ref, url); } }); + +test('a write notice renders its fixed line', () => { + const body = op => JSON.stringify({ notice: 'library-write', op, path: 'Notes/Eyes.md', commit: B }); + assert.deepEqual(renderReply(body('write'), { name: 'Scribe' }), + { kind: 'notice', op: 'write', path: 'Notes/Eyes.md', commit: B, text: 'Scribe wrote Notes/Eyes.md @bbbbbbb', plain: 'Scribe wrote Notes/Eyes.md @bbbbbbb' }); + assert.equal(renderReply(body('delete'), { name: 'Scribe' }).text, 'Scribe deleted Notes/Eyes.md @bbbbbbb'); + assert.equal(renderReply(body('write')).text, 'An agent wrote Notes/Eyes.md @bbbbbbb'); +}); + +test('a body without the reply or notice shape is free text: the whole body, no citations', () => { + const bodies = [ + JSON.stringify({ text: 't' }), JSON.stringify({ citations: [] }), JSON.stringify({ text: 1, citations: [] }), + JSON.stringify({ text: 't', citations: [], extra: 1 }), JSON.stringify({ items: [] }), + reply('t', [cite('a.md', [2, 1], 'q')]), reply('t', [cite('a.md', [0, 1], 'q')]), reply('t', [cite('a.md', [1], 'q')]), + reply('t', [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }]), reply('t', [cite('a.md', [1, 1], 'q', A, { as: 'playground' })]), + reply('t', [cite('a.md', [1, 1], 'q', A, { scope: 'canon' })]), reply('t', [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }]), + reply('t', [{ url: 'https://x.invalid' }]), reply('t', [{ url: 'https://x.invalid', quote: 'q', agent: 'x/y' }]), + JSON.stringify({ notice: 'library-write', op: 'move', path: 'a.md', commit: A }), + JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: 'abc' }), + JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: A, by: 'x' }), + ]; + for (const body of bodies) assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }, body); +}); + +test('an empty citations list is a reply with no cites', () => { + assert.deepEqual(renderReply(reply('Grey.', [])), { kind: 'reply', text: 'Grey.', citations: [], plain: 'Grey.' }); +}); diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs index 93dcbc169..9f4451b41 100644 --- a/packages/webui/src/bus.mjs +++ b/packages/webui/src/bus.mjs @@ -189,5 +189,7 @@ const STATUS = { // leaves the remote unreachable, like no bus to ask. 'library-sync-busy': 409, 'library-diverged': 409, 'library-accept-stale': 409, 'library-accept-invalid': 400, 'library-credential-missing': 503, 'library-fetch-failed': 503, + // Row 225 (C2-10b): a Revert of a commit that isn't an agent's is hers to read, like stale-base. + 'library-revert-not-agent': 409, }; export const busStatus = code => STATUS[code] ?? 502; diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 01c168b7f..2b77e88d5 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -68,14 +68,14 @@ try { // the system config, Business can't resolve authority and Settings has // no notifier. A refusal printed here names no path (redactor). // --send without a bus refuses to start rather than serve a composer that can't send. - let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null; + let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null; let processing = null, ownerCall = null; try { system = loadSystem(); const { dataRoot } = system; const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); // FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs). - ({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn })); + ({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn })); // Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with // --sso and --send. if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); @@ -107,7 +107,7 @@ try { } let server; try { - server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null }); + server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null }); } catch (err) { sso?.close(); throw err; } let testerServer = null; if (testerSocket) { diff --git a/packages/webui/src/console-bus.mjs b/packages/webui/src/console-bus.mjs index 3d310c1fe..df88b0ae7 100644 --- a/packages/webui/src/console-bus.mjs +++ b/packages/webui/src/console-bus.mjs @@ -30,10 +30,10 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) { projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args), libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args), - send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, + send: null, librarySync: null, libraryRevert: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, }; if (sendOn) { - o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args); + o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args); o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args); o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS); } diff --git a/packages/webui/src/public/chat.css b/packages/webui/src/public/chat.css index 64282189d..2f7ba38fb 100644 --- a/packages/webui/src/public/chat.css +++ b/packages/webui/src/public/chat.css @@ -23,3 +23,20 @@ .chat-session-controls label { display: block; } .chat-session-controls select, .chat-session-controls input { max-width: 100%; } .chat-session-controls form { margin: .6rem 0; } + +/* Row 225 (C2-10b, design 7.4): a reply's cites, a write notice and the library status line. The + badge and labels are text, so they read the same without colour. */ +.chat-cites { list-style: none; margin: .5rem 0 0; padding-inline-start: 1rem; display: grid; gap: .35rem; } +.chat-log .chat-cites li { padding: 0; border-bottom: 0; overflow-wrap: anywhere; font-size: .92rem; } +.chat-cites code { font-family: var(--mono); font-size: .85rem; } +.chat-scope, .chat-label { display: inline-block; padding: 0 .4rem; border: 1px solid var(--line); border-radius: var(--r); font-size: .78rem; } +.chat-scope-canon { border-color: var(--success); color: var(--success); } +.chat-scope-playground { border-color: var(--accent); color: var(--accent); } +.chat-scope-web { border-color: var(--action); color: var(--action); } +.chat-agent-written { color: var(--muted); } +.chat-changed { border-color: var(--warning); color: var(--warning); } +.chat-notice { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; white-space: normal; } +.chat-revert-receipt { overflow-wrap: anywhere; } +.chat-library { font-size: .9rem; margin-block: .5rem; overflow-wrap: anywhere; } +.chat-library p { margin: 0; } +.chat-library-problems ul { margin: .4rem 0 0; padding-inline-start: 1.25rem; } diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 3d2d3fa01..51d3d3e30 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -17,11 +17,22 @@ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false }); return forms.get(key); }; + // Row 225 (C2-10b, design 7.4): a reply renders through the library's renderReply, the one renderer the + // Discord relay shares. Until it loads, or if it can't, a body shows as its text. Badges, the + // agent-written label and "changed since" come from the bus's stored check (C2-06), never the body. + const SHA = /^[0-9a-f]{40}$/, CODE = /^[a-z][a-z-]{0,63}$/, NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/; + // The library verbs name the business project, which is the chat project's repository link. These + // codes mean that repository is no library, or this page may not read it: no status line at all. + const NO_LIBRARY = ['project-not-declared', 'library-mapping-invalid', 'project-not-configured', 'not-configured', 'owner-only']; + const libraryOf = (c, t) => { const repo = c?.projects.find(p => p.id === t?.project)?.repo; return typeof repo === 'string' && NAME.test(repo) ? repo : null; }; + let renderReply = null; + import('/render.mjs').then(m => { renderReply = m.renderReply; if (current && !out()) paint(); }, () => {}); + const libraries = new Map(), reverts = new Map(); class ChatError extends Error { constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; } } function forget() { - generation++; controller?.abort(); states.clear(); forms.clear(); + generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear(); if (current) current.container.replaceChildren(); current = null; } @@ -80,11 +91,92 @@ const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : ''; return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; } + // A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not + // its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing. + const changedBy = check => (cite, i) => { + const e = Array.isArray(check?.citations) ? check.citations[i] : null; + return SHA.test(e?.file ?? '') && SHA.test(e?.last ?? '') && e.file !== e.last; + }; + function cites(list) { + return `
    ${list.map(c => `
  • ${c.badge ? `${txt(c.badge)} ` : ''}${c.link ? `${txt(c.ref)}` : `${txt(c.ref)}`} ${txt(c.quote)}${c.agent ? ` agent-written: ${txt(c.agent)}` : ''}${c.changed ? ' changed since' : ''}
  • `).join('')}
`; + } + function messageBody(m) { + const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) }); + if (r?.kind === 'reply') return `
${txt(r.text)}
${r.citations.length ? cites(r.citations) : ''}`; + if (r?.kind === 'notice') { + const v = reverts.get(r.commit) ?? {}; + return `
${txt(r.text)} ${txt(v.receipt ?? '')}
`; + } + return `
${txt(m.body)}
`; + } + const ago = iso => { + const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000); + return !Number.isFinite(minutes) ? 'at an unknown time' : minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : minutes < 2880 ? `${Math.floor(minutes / 60)} h ago` : `${Math.floor(minutes / 1440)} days ago`; + }; + // library.status (C2-04): the served commit and when it was last fetched, a held divergence, the last + // fetch error, the reply limits in force (C2-06) and the R-problems, listed one click away. + function libraryLine(project) { + const l = libraries.get(project); + if (!l || l.hidden) return ''; + if (l.error) return `

Library status unavailable (${txt(l.error)}).

`; + const v = l.value, parts = []; + parts.push(SHA.test(v.served ?? '') ? `Library at ${txt(v.served.slice(0, 7))}` : 'Library not served yet'); + if (typeof v.lastFetch === 'string') parts.push(`fetched ${txt(ago(v.lastFetch))}`); + if (SHA.test(v.diverged?.commit ?? '')) parts.push(`library-diverged at ${txt(v.diverged.commit.slice(0, 7))}`); + if (CODE.test(v.lastError?.code ?? '')) parts.push(`fetch error ${txt(v.lastError.code)}`); + const limits = v.replyLimits; + if (limits && typeof limits === 'object') { + parts.push(`citations ${limits.requireCitation === true ? 'required' : 'optional'}${Number.isSafeInteger(limits.citationsPerReply) ? `, up to ${limits.citationsPerReply} per reply` : ''}`); + const fell = Array.isArray(v.replyLimitsFallback) ? v.replyLimitsFallback.filter(k => typeof k === 'string') : []; + if (fell.length) parts.push(`limits fell back: ${fell.map(k => `${txt(k)}`).join(', ')}`); + } + const problems = Array.isArray(v.problems) ? v.problems : []; + if (!problems.length) parts.push('no problems'); + const list = problems.length ? `
${problems.length} problem${problems.length === 1 ? '' : 's'}
    ${problems.map(p => `
  • ${txt(p?.code)} ${txt(p?.path)}${Number.isSafeInteger(p?.line) ? ':' + p.line : ''} ${txt(p?.message)}
  • `).join('')}
` : ''; + return `

${parts.join(' · ')}

${list}
`; + } + async function loadLibrary(project, g, signal) { + if (project === null) return; + let entry; + try { + const res = await fetch('/api/library/status?project=' + encodeURIComponent(project), { cache: 'no-store', redirect: 'manual', signal }); + if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; } + const data = await res.json().catch(() => null); + entry = res.ok && data && typeof data === 'object' && !Array.isArray(data) ? { value: data } : { error: CODE.test(data?.error ?? '') ? data.error : 'invalid-response' }; + } catch (err) { + if (err.name === 'AbortError') return; + entry = { error: 'unreachable' }; + } + if (g !== generation || out()) return; + libraries.set(project, NO_LIBRARY.includes(entry.error) ? { hidden: true } : entry); + paint(); + } + // design (writes): Revert is library.revert {commit}, her act. A refusal shows its code; before + // C2-02's verb exists that is the bus's refusal of the verb. + async function revert(commit) { + const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {}; + const project = libraryOf(c, t); + if (!c || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return; + if (!window.confirm(`Revert ${commit.slice(0, 7)}? This makes a new commit in the library that puts the file back as it was before it.`)) return; + const unknown = 'Outcome unknown. Read the library status before trying again; nothing was retried.'; + reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint(); + let receipt; + try { + const res = await fetch('/api/library/revert', { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project, commit }) }); + if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; } + const data = await res.json().catch(() => null); + receipt = res.ok && SHA.test(data?.commit ?? '') ? `Reverted in ${data.commit.slice(0, 7)}.` + : data?.sent === false && typeof data.error === 'string' ? `Not reverted (${inert(data.error.slice(0, 80))}).` : unknown; + } catch { receipt = unknown; } + if (out()) return; + reverts.set(commit, { pending: false, receipt }); + if (current) { paint(); void loadLibrary(project, generation); } + } function conversation(t) { const s = state(current.key); - const messages = s.rows.map(m => `
  • ${txt(m.from_role)} ${stamp(m.at)}
    ${txt(m.body)}
  • `).join(''); + const messages = s.rows.map(m => `
  • ${txt(m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}
  • `).join(''); const receipt = s.receipt; - return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${heading(t.name)}${sessionControls(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

      ${messages || '
    1. No messages yet.
    2. '}
    ${s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}

    ${txt(receipt)}

    `; + return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

      ${messages || '
    1. No messages yet.
    2. '}
    ${s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}

    ${txt(receipt)}

    `; } function capture() { if (!current) return null; @@ -233,7 +325,11 @@ } } catch (err) { if (err.name === 'AbortError') return; } } - if (g === generation) paint(navigated); + if (g === generation) { + paint(navigated); + const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null; + if (t) void loadLibrary(libraryOf(current, t), g, signal); + } } catch (err) { if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return; current.error = readError(err); @@ -340,6 +436,8 @@ if (event.target.id === 'chat-cancel') void cancelClear(); if (event.target.id === 'chat-clear') void clearContext(); if (event.target.id === 'chat-next') void refresh(true); + const revertButton = event.target.closest?.('[data-revert]'); + if (revertButton) void revert(revertButton.dataset.revert); if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); } }); document.addEventListener('change', event => { diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 376082853..25d1190d1 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -32,6 +32,8 @@ const files = new Map([ ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']], + // Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js. + ['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']], ['/editor.css', ['editor.css', 'text/css; charset=utf-8']], ['/icons.svg', ['icons.svg', 'image/svg+xml']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), @@ -42,6 +44,7 @@ const files = new Map([ const USER_FILES = new Map([ ['/', ['user.html', 'text/html; charset=utf-8']], ...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ['/render.mjs', files.get('/render.mjs')], ...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'), ]); @@ -596,12 +599,55 @@ async function projectFileRead(res, read, path, search) { return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code }); } } +// Row 225 (C2-10b, design 7.4): the chat's library status line and a write notice's Revert. The status +// is a read like the file list. The revert is her write (C2-02's `library.revert`), with the save's +// boundary: header user, exact Origin, same-origin Fetch Metadata, JSON, strict keys. Until the verb +// exists the bus refuses it, and the page shows that code. +const REVERT_KEYS = ['project', 'commit']; +async function libraryRevertSend(req, res, { libraryRevert, preview, log }) { + let target = null; + const line = outcome => log(`${new Date().toISOString()} library-revert user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} commit=${quoted(target?.commit)} ${outcome}`); + const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); }; + if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false }); + if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a revert needs the Console\'s own Origin'); + if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a revert needs same-origin Fetch Metadata'); + let value; + try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); } + if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== REVERT_KEYS.length ||!REVERT_KEYS.every(k => typeof value[k] === 'string') || !PATH_SHAPE.test(value.project) || !COMMIT.test(value.commit)) { + return refuse(400, 'invalid-request', 'a revert takes a project and a 40-hex commit, nothing else'); + } + target = value; + let result; + try { result = await libraryRevert({ project: value.project, commit: value.commit }); } catch (err) { + const { code, sent } = sendOutcome(err); + return sent === false + ? refuse(busStatus(code), code, `not reverted: the bus refused it (${code}).`) + : refuse(503, 'outcome-unknown', 'the revert may have been made; nothing was retried. Read the library status before trying again', 'unknown'); + } + if (!COMMIT.test(result?.commit) || result.reverted !== value.commit || typeof result.path !== 'string') return refuse(503, 'outcome-unknown', 'the bus answered without the new commit; the revert may have been made', 'unknown'); + line(`commit=${result.commit}`); + return json(res, 200, { project: value.project, path: result.path, reverted: result.reverted, commit: result.commit, at: new Date().toISOString() }); +} +async function libraryStatusRead(res, read, search) { + const query = new URLSearchParams(search); + if ([...query.keys()].some(k => k !== 'project') || query.getAll('project').length !== 1 || !PATH_SHAPE.test(query.get('project'))) + return json(res, 400, { error: 'invalid-request', message: 'a library status read takes a project' }); + if (!read) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' }); + try { + const result = await read({ project: query.get('project') }); + if (result === null || typeof result !== 'object' || Array.isArray(result) || result.project !== query.get('project')) throw new BusReadError('invalid-response'); + return json(res, 200, { ...result, 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 }); + } +} // `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`. // `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself. // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // tests; the field table is otherwise the committed packages/tasks/fields.json. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) { +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) { if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (host === 'localhost') host = '127.0.0.1'; if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); @@ -609,7 +655,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*'); if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1'); // Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads. - if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes'); + if (tester && [send, settingsWrite, projectSave, libraryRevert, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes'); if (tester && !testerSocket) throw new Error('tester listener requires private socket'); if (!tester && testerSocket) throw new Error('tester socket requires tester listener'); // Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester. @@ -619,7 +665,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session // user's own transport (console-bus.mjs). Only a signed-in listener has user sessions. if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso'); - const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files }; + const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files }; const hub = federationView(federation); // An object with none of the three is refused too: asking for the preview never serves without it. if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { @@ -629,7 +675,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso'); const server = createServer(async (req, res) => { // A granted user's request rebinds these below; every other request keeps the owner's. - let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false; + let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false; res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('referrer-policy', 'no-referrer'); // Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and @@ -696,7 +742,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' }); ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session)); - reads = null; upstream = null; files = USER_FILES; granted = true; + reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; } } if (named && preview !== null) { @@ -748,6 +794,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false }); return routesSend(req, res, { routesWrite, preview: writer, log }); } + if (path === '/api/library/revert') { + if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); + if (!send || !libraryRevert || !named) return json(res, 403, { error: 'read-only', sent: false }); + return libraryRevertSend(req, res, { libraryRevert, preview: signer, log }); + } if (path === '/api/project-file' && req.method === 'POST') { if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false }); return projectFileSend(req, res, { projectSave, preview: signer, log }); @@ -816,6 +867,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' }); return consoleRead(res, () => fieldTable(fieldsFile)); } + if (path === '/api/library/status') { + if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' }); + return libraryStatusRead(res, libraryStatus, search); + } if (path === '/api/project-files' || path === '/api/project-file') { if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' }); return projectFileRead(res, path === '/api/project-file' ? projectFile : projectFiles, path, search); diff --git a/packages/webui/tests/chat-replies-browser.test.mjs b/packages/webui/tests/chat-replies-browser.test.mjs new file mode 100644 index 000000000..ce3a1702e --- /dev/null +++ b/packages/webui/tests/chat-replies-browser.test.mjs @@ -0,0 +1,170 @@ +// Row 225 (C2-10b, design 7.4): replies, cites, write notices and the library status line in the +// Console chat. Fixture only: the chat and library answers are stubbed; /render.mjs is the real one. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { fixture, close } from './fixture.mjs'; +import { browser } from './browser.mjs'; +const P = '10000000-0000-4000-8000-000000000001'; +const A = '20000000-0000-4000-8000-000000000002'; +const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40), C3 = 'c'.repeat(40), C4 = 'd'.repeat(40); +const HOSTILE = ''; +const cite = (path, lines, quote, commit = C1, extra = {}) => ({ path, commit, lines, quote, ...extra }); +const reply = (text, citations) => JSON.stringify({ text, citations }); +const row = (id, body, check, from = 'assistant') => ({ id: `5000000${id}-0000-4000-8000-000000000000`, from_role: from, at: '2026-10-23T18:00:00.000Z', body, ...(check === undefined ? {} : { check }) }); +const ROWS = [ + row(1, 'Plain text answer.', undefined, 'jason'), + row(2, reply("Mara's eyes are grey.", [ + cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', C1, { as: 'canon' }), + cite('Book One/Playground/What If.md', [5, 6], 'a storm'), + cite('Notes/Eyes.md', [9, 10], 'green eyes'), + { url: 'https://example.invalid/tides', quote: 'spring tides' }, + { url: 'http://example.invalid/plain', quote: 'plain http' }, + { url: 'javascript:window.injected=2', quote: 'script' }, + ]), { citations: [ + { scope: 'canon', agent: null, file: C1, last: C1 }, + { scope: 'playground', agent: null, file: C2, last: C3 }, + { scope: 'supporting', agent: 'acme/scribe', file: C2 }, + { scope: 'web', agent: null }, { scope: 'web', agent: null }, { scope: 'web', agent: null }, + ] }), + row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])), + row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'), +]; +const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO', + lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' }, + problems: [{ code: 'R1', path: 'Notes/Eyes.md', message: 'no heading', line: 3 }, { code: 'R2', path: HOSTILE, message: 'bad name' }] }; +const stub = `(() => { + const real = fetch.bind(window); + window.__reverts=[];window.__revertAnswers=[];window.__statusReads=[];window.__confirms=[]; + window.__status={status:200,body:${JSON.stringify(STATUS)}}; + window.confirm=text=>{window.__confirms.push(text);return window.__confirm!==false;}; + window.fetch=async (url,init={})=>{ + const path=String(url), method=init.method||'GET'; + const answer=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'content-type':'application/json'}}); + if(path==='/api/config')return answer({send:!location.search.includes('readonly')}); + if(path.startsWith('/api/bus/'))return answer({rows:[],unread:0,at:new Date().toISOString()}); + if(path==='/api/business')return answer({projects:['books']}); + if(path.startsWith('/api/library/status')){ + window.__statusReads.push(path); + const body={...window.__status.body}; + if(body.lastFetch==='FIVE-MINUTES-AGO')body.lastFetch=new Date(Date.now()-330000).toISOString(); + return answer(body,window.__status.status); + } + if(path==='/api/library/revert'){ + window.__reverts.push({method,body:JSON.parse(init.body),headers:init.headers,redirect:init.redirect}); + const a=window.__revertAnswers.shift();if(!a)throw new Error('No revert answer'); + if(a.reject)throw new TypeError('network'); + return answer(a.body,a.status||200); + } + if(!path.startsWith('/api/chat'))return real(url,init); + const data=value=>answer({...value,at:new Date().toISOString()}); + if(path==='/api/chat/instances')return data({rows:[{instance:'assistant',name:'assistant'}]}); + if(path==='/api/chat/projects')return data({rows:[{id:'${P}',name:'Books',repo:window.__repo===undefined?'books':window.__repo}]}); + if(path.endsWith('/sessions'))return data({rows:[{id:'${A}',name:'default',default:true}]}); + if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}]}); + return data({rows:${JSON.stringify(ROWS)},after:null,hasMore:false}); + }; +})();`; +async function setup(t, { suffix = '', before = '' } = {}) { + const f = await fixture(); t.after(() => f.close()); + const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server)); + const b = await browser(); t.after(() => b.close()); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + before }); + await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`); + const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`); + const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`); + const message = n => `.chat-log > li:nth-child(${n})`; + await wait('document.querySelector(".chat-cites")'); + return { b, wait, text, message }; +} + +test('chat replies: free text, cites with badges, the agent label, changed since and safe links', async t => { + const { b, text, message } = await setup(t); + assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.'); + assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey."); + const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({ + badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent, + href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null, + quote: li.querySelector('q').textContent, agent: li.querySelector('.chat-agent-written')?.textContent ?? null, + changed: !!li.querySelector('.chat-changed') }))`); + assert.deepEqual(cites, [ + { badge: 'canon', ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', href: null, rel: null, quote: 'her grey eyes', agent: null, changed: false }, + { badge: 'playground', ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa', href: null, rel: null, quote: 'a storm', agent: null, changed: true }, + // `last` absent: nothing is marked changed (sage-3596). + { badge: 'supporting', ref: 'Notes/Eyes.md:9-10 @aaaaaaa', href: null, rel: null, quote: 'green eyes', agent: 'agent-written: acme/scribe', changed: false }, + { badge: 'web', ref: 'https://example.invalid/tides', href: 'https://example.invalid/tides', rel: 'noopener noreferrer', quote: 'spring tides', agent: null, changed: false }, + { badge: 'web', ref: 'http://example.invalid/plain', href: null, rel: null, quote: 'plain http', agent: null, changed: false }, + { badge: 'web', ref: 'javascript:window.injected=2', href: null, rel: null, quote: 'script', agent: null, changed: false }, + ]); + // A reply with no check result: no badge, no label, never changed; its quote is inert text. + assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(message(3) + ' .chat-scope, ' + message(3) + ' .chat-label')}).length`), 0); + assert.equal(await text(`${message(3)} q`), HOSTILE); + assert.equal(await b.evaluate('!!document.querySelector(".chat-log img") || window.injected'), false); +}); + +test('chat replies: a write notice reads as its line with a Revert; a refused revert shows its code', async t => { + const { b, wait, text, message } = await setup(t); + assert.equal(await text(`${message(4)} .chat-notice > span`), 'scribe wrote Notes/Eyes.md @ddddddd'); + assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).dataset.revert`), C4); + assert.equal(await b.evaluate('document.querySelectorAll("[data-revert]").length'), 1, 'only the notice has a Revert'); + // Cancelled at the confirm: nothing is sent. + await b.evaluate('window.__confirm=false;document.querySelector("[data-revert]").click();true'); + assert.equal(await b.evaluate('window.__reverts.length'), 0); + assert.match(await b.evaluate('window.__confirms[0]'), /^Revert ddddddd\?/); + // Before C2-02 the bus refuses the verb; the page shows that code and doesn't retry. + await b.evaluate(`window.__confirm=true;window.__revertAnswers.push({status:502,body:{error:'unknown-verb',message:'not reverted: the bus refused it (unknown-verb).',sent:false}});document.querySelector("[data-revert]").click();true`); + await wait('document.querySelector(".chat-revert-receipt").textContent.startsWith("Not")'); + assert.equal(await text('.chat-revert-receipt'), 'Not reverted (unknown-verb).'); + assert.deepEqual(await b.evaluate('window.__reverts'), [{ method: 'POST', body: { project: 'books', commit: C4 }, headers: { 'content-type': 'application/json' }, redirect: 'manual' }]); + for (const [answer, receipt] of [ + [{ status: 409, body: { error: 'library-revert-not-agent', sent: false } }, 'Not reverted (library-revert-not-agent).'], + [{ status: 409, body: { error: 'stale-base', sent: false } }, 'Not reverted (stale-base).'], + [{ status: 503, body: { error: 'outcome-unknown', sent: 'unknown' } }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'], + [{ reject: true }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'], + [{ body: { project: 'books', path: 'Notes/Eyes.md', reverted: C4, commit: C1 } }, 'Reverted in aaaaaaa.'], + ]) { + const n = await b.evaluate('window.__reverts.length'); + await b.evaluate(`window.__revertAnswers.push(${JSON.stringify(answer)});document.querySelector("[data-revert]").click();true`); + await wait(`window.__reverts.length===${n + 1} && !document.querySelector("[data-revert]").disabled`); + assert.equal(await text('.chat-revert-receipt'), receipt); + } + assert.ok(await b.evaluate('window.__statusReads.length') >= 2, 'the status line is read again after a revert'); +}); + +test('chat replies: a read-only Console shows the notice with Revert disabled', async t => { + const { b, message } = await setup(t, { suffix: '?readonly' }); + assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).disabled`), true); + await b.evaluate('document.querySelector("[data-revert]").click();true'); + assert.equal(await b.evaluate('window.__reverts.length + window.__confirms.length'), 0); +}); + +test('chat status line: served commit and age, divergence, fetch error and the problems one click away', async t => { + const { b, wait, text } = await setup(t); + await wait('document.querySelector(".chat-library")'); + assert.equal(await b.evaluate('window.__statusReads[0]'), '/api/library/status?project=books'); + assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · library-diverged at bbbbbbb · fetch error library-fetch-failed'); + assert.equal(await text('.chat-library-problems summary'), '2 problems'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".chat-library-problems li")].map(li => li.textContent)'), + ['R1 Notes/Eyes.md:3 no heading', `R2 ${HOSTILE} bad name`]); + assert.equal(await b.evaluate('!!document.querySelector(".chat-library img")'), false); +}); + +test('chat status line: limits when the status carries them, no problems, and refusals', async t => { + const limits = { ...STATUS, lastError: null, diverged: null, problems: [], replyLimits: { requireCitation: true, citationsPerReply: 4 }, replyLimitsFallback: ['citationsPerReply'] }; + const { b, wait, text } = await setup(t, { before: `window.__status={status:200,body:${JSON.stringify(limits)}};` }); + await wait('document.querySelector(".chat-library")'); + assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems'); + assert.equal(await b.evaluate('!!document.querySelector(".chat-library-problems")'), false); + for (const [status, error, shown] of [[502, 'library-state-invalid', 'Library status unavailable (library-state-invalid).'], [502, 'Not A Code', 'Library status unavailable (invalid-response).'], + [403, 'owner-only', null], [502, 'project-not-declared', null], [502, 'library-mapping-invalid', null], [503, 'project-not-configured', null], [503, 'not-configured', null]]) { + await wait('!document.querySelector("#refresh").disabled'); + await b.evaluate(`window.__status={status:${status},body:{error:${JSON.stringify(error)}}};document.querySelector("#refresh").click();true`); + await wait(`window.__statusReads.length && document.querySelector(".chat-conversation") && ${shown === null ? '!document.querySelector(".chat-library")' : `document.querySelector(".chat-library")?.textContent===${JSON.stringify(shown)}`}`); + } +}); + +test('chat status line: a project with no repository link reads no library status', async t => { + const { b } = await setup(t, { before: 'window.__repo=null;' }); + assert.equal(await b.evaluate('window.__statusReads.length'), 0); + assert.equal(await b.evaluate('!!document.querySelector(".chat-library")'), false); +}); diff --git a/packages/webui/tests/library-routes.test.mjs b/packages/webui/tests/library-routes.test.mjs new file mode 100644 index 000000000..24153e3df --- /dev/null +++ b/packages/webui/tests/library-routes.test.mjs @@ -0,0 +1,142 @@ +// Row 225 (C2-10b, design 7.4): the chat's library status read, a write notice's Revert and the +// shared reply renderer served to the page. The revert is C2-02's `library.revert`; until that verb +// exists the bus refuses it and the route reports the code. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { startServer, userRoute } from '../src/serve.mjs'; +import { BusReadError } from '../src/bus.mjs'; +const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; +const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; +const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40); +const payload = { project: 'books', commit: C1 }; +const STATUS = { project: 'books', version: 1, branch: 'main', served: C2, lastFetch: '2026-10-23T18:00:00.000Z', lastError: null, diverged: null, problems: [] }; +async function setup(t, { enabled = true, revert, status, lines = [] } = {}) { + const calls = { revert: [], status: [] }; + const server = await startServer({ + port: 0, preview, log: l => lines.push(l), + send: enabled ? async () => ({}) : null, + libraryStatus: status === null ? null : async args => { calls.status.push(args); return status ? status(args) : STATUS; }, + libraryRevert: enabled ? async args => { calls.revert.push(args); return revert ? revert(args) : { project: args.project, path: 'Notes/Eyes.md', reverted: args.commit, commit: C2 }; } : null, + }); + t.after(() => new Promise(r => server.close(r))); + return { calls, lines, port: server.address().port }; +} +function call(s, { method = 'POST', path = '/api/library/revert', hs = headers, data = payload } = {}) { + return new Promise((resolve, reject) => { + const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => { + let text = ''; + r.on('data', c => text += c); + r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: text.startsWith('{') ? JSON.parse(text) : null })); + }); + req.on('error', reject); + req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data)); + }); +} +const get = (s, path, hs = headers) => call(s, { method: 'GET', path, hs, data: null }); +const refusal = code => { throw new BusReadError(code); }; + +test('a signed-in same-origin revert reaches exactly one library.revert and returns the new commit', async t => { + const s = await setup(t), r = await call(s); + assert.equal(r.status, 200, r.text); + assert.deepEqual(s.calls.revert, [payload]); + assert.deepEqual({ ...r.body, at: undefined }, { project: 'books', path: 'Notes/Eyes.md', reverted: C1, commit: C2, at: undefined }); + assert.ok(s.lines.some(l => l.includes('library-revert') && l.includes(`commit=${C2}`) && l.includes('project="books"'))); +}); + +test('a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata', async t => { + const off = await setup(t, { enabled: false }); + assert.deepEqual((await call(off)).body, { error: 'read-only', sent: false }); + const s = await setup(t); + for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403], + ['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['x-user', ['jason', 'jason'], 401]]) { + const hs = { ...headers }; + if (value === undefined) delete hs[key]; else hs[key] = value; + const r = await call(s, { hs }); + assert.equal(r.status, status, key); + assert.notEqual(r.body?.sent, true); + } + assert.equal((await call(s, { method: 'GET', data: null })).status, 405); + assert.deepEqual(s.calls.revert, []); +}); + +test('a revert takes a project and a 40-hex commit, nothing else, before the bus is asked', async t => { + const s = await setup(t); + for (const data of ['not json', 'null', '[]', '5', { project: 'books' }, { commit: C1 }, { ...payload, path: 'a.md' }, + { project: 'books', commit: 'abc' }, { project: 'books', commit: C1.toUpperCase() }, { project: '../x', commit: C1 }, { project: 7, commit: C1 }]) { + const r = await call(s, { data }); + assert.equal(r.status, 400, JSON.stringify(data)); + assert.equal(r.body.error, 'invalid-request'); + assert.equal(r.body.sent, false); + } + assert.deepEqual(s.calls.revert, []); +}); + +test('a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal', async t => { + const codes = { 'unknown-verb': 502, 'library-revert-not-agent': 409, 'stale-base': 409, 'human-required': 403, 'project-not-configured': 503, 'invalid-request': 400 }; + for (const [code, status] of Object.entries(codes)) { + const s = await setup(t, { revert: () => refusal(code) }), r = await call(s); + assert.equal(r.status, status, code); + assert.equal(r.body.error, code); + assert.equal(r.body.sent, false); + assert.match(r.body.message, new RegExp(`not reverted: the bus refused it \\(${code}\\)`)); + assert.equal(s.calls.revert.length, 1, 'never retried'); + } +}); + +test('a revert whose answer is lost or unusable is reported unknown and is never retried', async t => { + const answers = [() => refusal('outcome-unknown'), () => { throw new Error('socket closed'); }, () => ({ project: 'books', path: 'a.md', reverted: C1 }), + () => ({ project: 'books', path: 'a.md', reverted: C2, commit: C2 }), () => ({ project: 'books', reverted: C1, commit: C2 }), () => null]; + for (const revert of answers) { + const s = await setup(t, { revert }), r = await call(s); + assert.equal(r.status, 503, revert.toString()); + assert.equal(r.body.error, 'outcome-unknown'); + assert.equal(r.body.sent, 'unknown'); + assert.equal(s.calls.revert.length, 1); + } +}); + +test('the status read takes one project query and answers the bus result for that project', async t => { + const s = await setup(t), r = await get(s, '/api/library/status?project=books'); + assert.equal(r.status, 200, r.text); + assert.deepEqual(s.calls.status, [{ project: 'books' }]); + assert.deepEqual({ ...r.body, at: undefined }, { ...STATUS, at: undefined }); + for (const path of ['/api/library/status', '/api/library/status?project=books&x=1', '/api/library/status?project=books&project=other', '/api/library/status?project=../x']) + assert.equal((await get(s, path)).status, 400, path); + assert.equal((await call(s, { path: '/api/library/status?project=books' })).status, 405); + assert.equal(s.calls.status.length, 1); +}); + +test('a status answer for another project or of the wrong shape is refused; a refusal keeps its status', async t => { + for (const answer of [{ ...STATUS, project: 'other' }, null, [], 'books']) { + const s = await setup(t, { status: () => answer }), r = await get(s, '/api/library/status?project=books'); + assert.equal(r.status, 502, JSON.stringify(answer)); + assert.equal(r.body.error, 'invalid-response'); + } + for (const [code, status] of Object.entries({ 'human-required': 403, 'project-not-configured': 503, 'project-not-declared': 502, 'library-mapping-invalid': 502 })) { + const s = await setup(t, { status: () => refusal(code) }), r = await get(s, '/api/library/status?project=books'); + assert.equal(r.status, status, code); + assert.equal(r.body.error, code); + } + const none = await setup(t, { status: null }); + assert.equal((await get(none, '/api/library/status?project=books')).body.error, 'not-configured'); +}); + +test('the status read needs no send; a read-only Console still has no revert', async t => { + const s = await setup(t, { enabled: false }); + assert.equal((await get(s, '/api/library/status?project=books')).status, 200); + assert.equal((await call(s)).status, 403); +}); + +test('library routes are the owner\'s: a granted user is refused them, and the tester never reverts', async t => { + for (const [path, method] of [['/api/library/status', 'GET'], ['/api/library/revert', 'POST']]) assert.equal(userRoute(path, method), false, path); + await assert.rejects(startServer({ port: 0, tester: true, testerSocket: '/nonexistent', libraryRevert: async () => ({}) }), /tester listener permits only/); +}); + +test('the page gets the one library renderer, byte for byte, as a JavaScript module', async t => { + const s = await setup(t), r = await get(s, '/render.mjs'); + assert.equal(r.status, 200); + assert.match(r.type, /^text\/javascript/); + assert.equal(r.text, readFileSync(new URL('../../library/src/render.mjs', import.meta.url), 'utf8')); +});