import type { IControllerFailureDetail } from '../ts_interfaces/index.js'; /** * The workspace error journal. * * A failed workspace operation used to occupy a banner across the top of the workspace until * something else replaced it. Errors are events, not a state of the workspace, so they are * collected here instead: the header names how many are unseen, and the Errors dialog shows each * one with the cause the controller kept under its reference, in a form that can be copied into a * bug report. The journal lives in the browser tab only and is dropped on reload. */ /** How many entries are retained; older ones are dropped. */ export const maxErrorJournalEntries = 50; /** One failed workspace operation, as the Errors dialog shows it. */ export interface IErrorJournalEntry { id: string; at: number; /** What the UI was doing, in the owner's words, for example `Send the message`. */ operation: string; /** The message the operation failed with, as the controller phrased it for a client. */ message: string; /** Present when the controller answered with its generic failure and journaled the cause. */ reference?: string; /** The controller-side cause, once it was read back by reference. */ detail?: IControllerFailureDetail; seen: boolean; } /** Appends one entry and enforces the retention bound. */ export const appendErrorJournalEntry = ( entriesArg: readonly IErrorJournalEntry[], entryArg: IErrorJournalEntry, ): IErrorJournalEntry[] => [...entriesArg, entryArg].slice(-maxErrorJournalEntries); const formatTimestamp = (valueArg: number): string => ( Number.isFinite(valueArg) ? new Date(valueArg).toISOString() : 'unknown' ); const formatEntry = (entryArg: IErrorJournalEntry, indexArg: number): string => { const lines = [ `[${indexArg}] ${formatTimestamp(entryArg.at)} - ${entryArg.operation}`, `message: ${entryArg.message}`, ]; if (entryArg.reference !== undefined) lines.push(`reference: ${entryArg.reference}`); const detail = entryArg.detail; if (detail !== undefined) { lines.push( `controller operation: ${detail.operation}`, `controller time: ${formatTimestamp(detail.at)}`, `controller cause: ${detail.name}: ${detail.message}`, ); if (detail.stack) lines.push('controller stack:', detail.stack); } else if (entryArg.reference !== undefined) { // Stated rather than omitted: the reference alone is still worth reporting, and the cause is // unavailable either because it has not been read back yet or because the controller // dropped it — the report must not claim to know which. lines.push('controller cause: unavailable'); } return lines.join('\n'); }; /** * Renders entries as the plain text the Copy actions put on the clipboard. Pure so the exact * report a bug gets attached to can be asserted: the timestamp is an input, not `Date.now()`. */ export const formatErrorJournalReport = (inputArg: { version: string; generatedAt: number; entries: readonly IErrorJournalEntry[]; }): string => { const header = [ 'AGL error report', `agl version: ${inputArg.version || 'unknown'}`, `generated: ${formatTimestamp(inputArg.generatedAt)}`, `entries: ${inputArg.entries.length}`, ].join('\n'); if (inputArg.entries.length === 0) return `${header}\n`; const body = inputArg.entries .map((entry, index) => formatEntry(entry, index + 1)) .join('\n\n'); return `${header}\n\n${body}\n`; };