feat(ui): frontend log file, written by the page and served by the server

The browser console was the only place Atlas wrote to, and it dies with
the tab: a reader who saw "get_code_snippet returned no source" had
nothing to hand over, and an agent looking into it had nothing to read.
The page now keeps a second copy of everything it logs, on the server,
next to the daemon's own records.

Server (src/ui/http_server.c):

- POST /api/ui-log takes {page, session, entries[]} and appends one JSON
  line per entry to <cache_dir>/logs/ui.log (received, page, session,
  seq, ts, level, source, message, detail, stack, url, line, col).
  Bounds: 64 KiB per post, 100 entries per post, 4096 characters per
  text field cut on a UTF-8 boundary and marked, the level normalised to
  a known word, every string escaped again on the way out. The file
  rotates once to ui.log.1 at CBM_UI_LOG_ROTATE_BYTES (default 5 MiB).
  Warnings and errors are mirrored into the ring GET /api/logs serves,
  so both sides of one failure sit in one list; the chatter stays in
  the file. The reply names the file and says whether it could be
  written.
- GET /api/ui-log?lines=N tails the file (default 200, at most 1000,
  from the last 256 KiB) and names the file and its rotated
  predecessor; `partial` says when the lines do not start at the top.
- cbm_ui_log_file_path() is the one place the path is built.

Frontend (graph-ui):

- app/ui-log.ts: a bounded buffer (200 entries, oldest dropped first
  and the loss named at the head of the next batch), batched posts
  (25 entries or 1.5 s), backoff on a refused post, never writes to the
  console itself. app/ui-log-transport.ts posts with fetch, and with a
  beacon when the page is going away.
- app/ui-log-install.ts wraps the five console methods (they still
  print; the copy is taken after), listens to window error and
  unhandledrejection, and flushes on pagehide. Installed first in
  main.tsx, so nothing can fail before the copy exists.
- provider/error-observer.ts is the seam for failures the panels catch
  and show as a sentence: the /rpc transport and the /api client
  announce every failed call there before throwing, with a level that
  tells a tool the server does not offer (info) from a tool that said
  no (warn) from a broken or unreachable server (error). The reader
  announces a file the index does not know.
- The projects panel shows the tail under "This server" as sentences,
  names the file to attach to a report, and reloads it with the rest.
- README (both) and INVENTAR.md name the file and the routes.

Tests: three httpd cases (JSON lines and tail, refused bodies and
dropped entries, rotation); vitest for the buffer, the wiring in jsdom,
the transport, the seam through both clients, the API method, the model
and the panel. Verified live against a real project: the reader's
failure appears in the file and in the panel within two seconds.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-09-08 01:26:33 +02:00
1 parent a338c1cae1
commit 9dc882a82f
23 files changed
+1996 -9

No files matched your search

+2
View File
@@ -143,6 +143,8 @@ Open `http://localhost:9749` in your browser. The UI is owned by the shared coor
Atlas is **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess.
When something in Atlas does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file.
- **Explorer and reader** — the indexed file tree and a read-only Monaco reader whose source comes from the index.
- **Semantic twin** — follows the caret: what the symbol under it holds, calls, raises and touches, as facts or as pseudocode, pitched at who is reading.
- **Reading modes** — `[w]hy am I here` picks a way in (hunt a bug, scope a change, understand the project, pick an entry point); **flow** walks the calls in the order they run; **bug hunt** compares the expected path into a symbol with what `ingest_traces` observed; **change scope** shows what a change reaches and what covers it.
+6 -3
View File
@@ -73,15 +73,18 @@ fuer W1; die daraus folgenden Luecken stehen in UPSTREAM-ASKS.md.
(src/mcp/mcp.c:376-711) plus Alias trace_call_path (mcp.c:11564); drei
Tool-Profile all/analysis/scout; tools/list paginiert (Seitengroesse 8).
## 3. /api-Routen (28 benannte Routen plus / und /assets/*)
## 3. /api-Routen (30 benannte Routen plus / und /assets/*)
GET-Routen (src/ui/http_server.c:2545-2714): /__cbm/ui-readiness
(HMAC-Challenge), /api/layout, /api/tree, /api/symbol-history, /api/who,
/api/symbol, /api/metrics, /api/why, /api/handout, /api/blast, /api/bridges,
/api/scent, /api/trace, /api/impact, /api/flows, /api/flow, /api/repo-info,
/api/index-status, /api/ui-config, /api/browse, /api/adr,
/api/project-health, /api/processes, /api/logs. POST: /rpc, /api/index,
/api/adr. DELETE: /api/project. Dazu / und /assets/* aus dem Embedding.
/api/project-health, /api/processes, /api/logs, /api/ui-log (Schwanz der
Frontend-Logdatei). POST: /rpc, /api/index, /api/adr, /api/ui-log (das
Frontend schreibt Konsole, Fehler und gescheiterte Anfragen in
`<cache_dir>/logs/ui.log`). DELETE: /api/project. Dazu / und /assets/* aus
dem Embedding.
- /api/tree liefert den indexierten Datei-Baum aus dem Graphen (empirisch
bestaetigt), NICHT das Dateisystem.
- /api/browse ist ein Verzeichnis-Picker fuer die Index-Anlage (empirisch:
+9
View File
@@ -20,6 +20,15 @@ What the maintainers added on top:
check or remove an index, edit the decision record, read the server's
processes and log. It is the one surface that asks the server to write,
and it names every route it uses.
- **The frontend log** (`src/app/ui-log.ts`, installed in `src/main.tsx`):
the console keeps printing, and a copy of every console line, uncaught
error, unhandled rejection and failed `/rpc` or `/api` call is batched
to `POST /api/ui-log`, which the server appends as JSON lines to
`<cache_dir>/logs/ui.log` (rotating once at 5 MiB). `GET /api/ui-log`
tails it, the projects panel shows it under "This server", and a bug
report attaches the file. The caught request failures reach the log
through `src/provider/error-observer.ts`, the seam the two clients
announce on before they throw.
- **Gates in CI** (`.github/workflows/_test.yml`, job `test-ui`, which
runs `scripts/ci/test-ui.sh`; run that script locally for the identical
leg): `npm run test:unit`, `npm run check:style`,
+8
View File
@@ -156,6 +156,7 @@ import { CbmRpcProvider, symbolKindOf } from './provider/cbm-rpc-provider';
import MonacoReader from './reader/MonacoReader';
import type { ReaderStatus } from './reader/MonacoReader';
import { FileNotReadableError, loadFileDocument, READER_RPC_TOOL } from './reader/file-source';
import { reportError } from './provider/error-observer';
import type { ReaderDocument } from './reader/file-source';
import { badgesForLines } from './core/step-badge-decorator';
import type { SymbolRef } from './core/focus-protocol';
@@ -756,6 +757,7 @@ export default function App(): JSX.Element {
saveAdr: (name, content) => api.saveAdr(name, content),
logs: (lines) => api.logs(lines),
processes: () => api.processes(),
uiLogTail: (lines) => api.uiLogTail(lines),
}),
[client, api],
);
@@ -1758,6 +1760,12 @@ export default function App(): JSX.Element {
setDocument(undefined);
setReaderStatus(error instanceof FileNotReadableError ? 'unavailable' : 'failed');
setReaderMessage(error instanceof Error ? error.message : String(error));
// The reader shows the sentence; the frontend log keeps it
// (the /rpc failures underneath announce themselves, a file
// the index does not know does not).
if (error instanceof FileNotReadableError) {
reportError({ source: 'reader', level: 'warn', message: error.message });
}
});
},
[client, project],
+15
View File
@@ -94,6 +94,21 @@ describe('AtlasApi, the projects routes', () => {
expect(processes.calls[0]?.url).toBe('http://127.0.0.1:9749/api/processes');
});
it('reads the frontend log tail with its file', async () => {
const { api: client, calls } = api({
body: '{"path":"/c/logs/ui.log","previous_path":"/c/logs/ui.log.1","size_bytes":77,"partial":true,"lines":["{}"],"total":3}',
});
expect(await client.uiLogTail(50)).toEqual({
path: '/c/logs/ui.log',
previousPath: '/c/logs/ui.log.1',
sizeBytes: 77,
partial: true,
lines: ['{}'],
total: 3,
});
expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/ui-log?lines=50');
});
it('carries the status of a refusal, so busy can be told from forbidden', async () => {
const { api: client } = api({ ok: false, status: 423, body: '{"error":"project is busy; retry after indexing"}' });
await expect(client.saveAdr('p', 'x')).rejects.toSatisfy((error: unknown) =>
+40 -3
View File
@@ -13,6 +13,7 @@
* `base` ist darum nur fuer Node-Aufrufer da, die keinen Origin senden.
*/
import { hasErrorObservers, reportError } from '../provider/error-observer';
import { readTreeLevel } from './tree-model';
import type { TreeLevel } from './tree-model';
import {
@@ -29,6 +30,7 @@ import {
readIndexStarted,
readLogs,
readProcesses,
readUiLogTail,
} from '../projects/projects-model';
import type {
AdrRecord,
@@ -38,6 +40,7 @@ import type {
LogTail,
ProcessReport,
ProjectHealth,
UiLogTail,
} from '../projects/projects-model';
export interface AtlasApiOptions {
@@ -70,6 +73,25 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Announce a failed /api call before it is thrown (provider/error-observer.ts),
* so the frontend log holds it even though the panel catches it and shows a
* sentence instead. A 4xx is the server saying no to this request (warn);
* no answer, a 5xx or an unreadable body is a failure (error).
*/
function announce(error: AtlasApiError, detail?: string): AtlasApiError {
if (hasErrorObservers()) {
const refused = error.status >= 400 && error.status < 500;
reportError({
source: 'api',
level: refused ? 'warn' : 'error',
message: error.message,
detail,
});
}
return error;
}
const text = (value: unknown): string => (typeof value === 'string' ? value : '');
export class AtlasApi {
@@ -99,16 +121,22 @@ export class AtlasApi {
response = await doFetch(url, init);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new AtlasApiError(route, 0, `${route} war nicht erreichbar: ${message}`);
throw announce(new AtlasApiError(route, 0, `${route} war nicht erreichbar: ${message}`));
}
const body = await response.text();
if (!response.ok) {
throw new AtlasApiError(route, response.status, `${route} antwortete mit HTTP ${response.status}: ${body.slice(0, 200)}`);
throw announce(
new AtlasApiError(route, response.status, `${route} antwortete mit HTTP ${response.status}: ${body.slice(0, 200)}`),
body.slice(0, 400),
);
}
try {
return JSON.parse(body) as unknown;
} catch {
throw new AtlasApiError(route, response.status, `${route} lieferte kein JSON: ${body.slice(0, 200)}`);
throw announce(
new AtlasApiError(route, response.status, `${route} lieferte kein JSON: ${body.slice(0, 200)}`),
body.slice(0, 400),
);
}
}
@@ -206,6 +234,15 @@ export class AtlasApi {
return readProcesses(await this.getJson('/api/processes'));
}
/**
* The tail of the frontend's own log file on the server, the one this
* page writes through POST /api/ui-log (app/ui-log.ts). The answer names
* the file, so the panel can tell a reader what to attach to a report.
*/
async uiLogTail(lines: number): Promise<UiLogTail> {
return readUiLogTail(await this.getJson(`/api/ui-log?${new URLSearchParams({ lines: String(lines) }).toString()}`));
}
/** Wurzelpfad, Branch und Remote eines Projekts. Leere Felder bleiben leer. */
async repoInfo(project: string): Promise<RepoInfo> {
const raw = await this.getJson(`/api/repo-info?${new URLSearchParams({ project }).toString()}`);
+198
View File
@@ -0,0 +1,198 @@
// @vitest-environment jsdom
/*
* The frontend log wired into a page: the console still prints, and what it
* printed is recorded; uncaught errors, rejected promises and the failures
* the clients announce all reach the buffer; uninstall puts the console
* back. The transport is a recorder, so nothing here touches a network.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { reportError } from '../provider/error-observer';
import { UiLogBuffer } from './ui-log';
import type { UiLogPayload } from './ui-log';
import { describeArgs, installUiLog, safeStringify } from './ui-log-install';
import type { UiLogHandle } from './ui-log-install';
import { httpUiLogTransport } from './ui-log-transport';
let handle: UiLogHandle | undefined;
afterEach(() => {
handle?.uninstall();
handle = undefined;
});
function install() {
const posts: { payload: UiLogPayload; final: boolean }[] = [];
const buffer = new UiLogBuffer({
page: '/?project=demo',
session: 'test',
transport: {
send: async (payload, final) => {
posts.push({ payload, final });
return true;
},
},
schedule: () => 0,
cancel: () => undefined,
});
const printed: unknown[][] = [];
const fakeConsole = {
debug: (...args: unknown[]) => printed.push(['debug', ...args]),
log: (...args: unknown[]) => printed.push(['log', ...args]),
info: (...args: unknown[]) => printed.push(['info', ...args]),
warn: (...args: unknown[]) => printed.push(['warn', ...args]),
error: (...args: unknown[]) => printed.push(['error', ...args]),
} as unknown as Console;
handle = installUiLog({ buffer, console: fakeConsole, session: 'test' });
return { buffer, posts, printed, fakeConsole };
}
async function entries(buffer: UiLogBuffer, posts: { payload: UiLogPayload }[]) {
await buffer.flush();
return posts.flatMap((post) => post.payload.entries);
}
describe('installUiLog', () => {
it('starts the session with a line that names page, build and browser', async () => {
const { buffer, posts } = install();
const all = await entries(buffer, posts);
expect(all[0]?.source).toBe('ui-log');
expect(all[0]?.message).toBe('session test started on /');
expect(all[0]?.detail).toContain('build v');
});
it('lets the console print as before and records what it printed', async () => {
const { buffer, posts, printed, fakeConsole } = install();
const failure = new Error('twin failed');
fakeConsole.error('reader', failure);
fakeConsole.warn('slow frame', { ms: 41 });
fakeConsole.log('plain');
expect(printed).toEqual([['error', 'reader', failure], ['warn', 'slow frame', { ms: 41 }], ['log', 'plain']]);
const all = await entries(buffer, posts);
const fromConsole = all.filter((entry) => entry.source === 'console');
expect(fromConsole.map((entry) => [entry.level, entry.message])).toEqual([
['error', 'reader Error: twin failed'],
['warn', 'slow frame {"ms":41}'],
['log', 'plain'],
]);
expect(fromConsole[0]?.stack).toContain('twin failed');
});
it('records an uncaught error with its file, line and column', async () => {
const { buffer, posts } = install();
window.dispatchEvent(new ErrorEvent('error', {
message: 'Uncaught TypeError: x is not a function',
filename: 'http://127.0.0.1:9749/assets/index.js',
lineno: 12,
colno: 7,
error: new TypeError('x is not a function'),
}));
const all = await entries(buffer, posts);
const uncaught = all.find((entry) => entry.source === 'window');
expect(uncaught?.level).toBe('error');
expect(uncaught?.message).toBe('Uncaught TypeError: x is not a function');
expect(uncaught?.url).toBe('http://127.0.0.1:9749/assets/index.js');
expect(uncaught?.line).toBe(12);
expect(uncaught?.col).toBe(7);
expect(uncaught?.stack).toContain('x is not a function');
});
it('records an unhandled rejection', async () => {
const { buffer, posts } = install();
const event = new Event('unhandledrejection') as Event & { reason: unknown };
event.reason = new Error('nobody caught this');
window.dispatchEvent(event);
const all = await entries(buffer, posts);
const rejected = all.find((entry) => entry.source === 'promise');
expect(rejected?.level).toBe('error');
expect(rejected?.message).toBe('Error: nobody caught this');
});
it('records what the clients announce through the observer seam', async () => {
const { buffer, posts } = install();
reportError({ source: 'rpc', level: 'error', message: '/rpc get_code_snippet: HTTP 500', detail: 'oom' });
const all = await entries(buffer, posts);
const announced = all.find((entry) => entry.source === 'rpc');
expect(announced?.message).toBe('/rpc get_code_snippet: HTTP 500');
expect(announced?.detail).toBe('oom');
});
it('flushes with final when the page hides', () => {
const { buffer } = install();
const flush = vi.spyOn(buffer, 'flush');
window.dispatchEvent(new Event('pagehide'));
expect(flush).toHaveBeenCalledWith(true);
});
it('puts the console back on uninstall and stops listening', async () => {
const { buffer, posts, printed, fakeConsole } = install();
const wrapped = fakeConsole.log;
handle?.uninstall();
handle = undefined;
expect(fakeConsole.log).not.toBe(wrapped);
fakeConsole.log('after');
reportError({ source: 'api', level: 'warn', message: 'late' });
expect(printed[printed.length - 1]).toEqual(['log', 'after']);
const all = await entries(buffer, posts);
expect(all.some((entry) => entry.message === 'after')).toBe(false);
expect(all.some((entry) => entry.message === 'late')).toBe(false);
});
});
describe('describeArgs and safeStringify', () => {
it('renders strings as they are, errors with their name, objects as JSON with cycles held', () => {
const loop: Record<string, unknown> = { name: 'loop' };
loop['self'] = loop;
expect(describeArgs(['a', 1, true, null, undefined, loop]).message)
.toBe('a 1 true null undefined {"name":"loop","self":"[circular]"}');
expect(safeStringify(10n)).toBe('10n');
expect(safeStringify(() => 1)).toBe('[function anonymous]');
// A string is passed through as it is; the field cap applies later in
// the buffer. An object is cut here, so one huge argument cannot
// crowd out the rest of the line.
expect(safeStringify('x'.repeat(2500)).length).toBe(2500);
expect(safeStringify({ s: 'x'.repeat(2500) }).length).toBeLessThan(2100);
});
});
describe('httpUiLogTransport', () => {
it('posts JSON to /api/ui-log and reports the server verdict', async () => {
const calls: { url: string; init: RequestInit | undefined }[] = [];
const transport = httpUiLogTransport({
base: 'http://127.0.0.1:9749',
fetch: ((url: string, init?: RequestInit) => {
calls.push({ url, init });
return Promise.resolve({ ok: true } as Response);
}) as unknown as typeof globalThis.fetch,
});
const payload: UiLogPayload = { page: '/', session: 's', entries: [] };
expect(await transport.send(payload, false)).toBe(true);
expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/ui-log');
expect(calls[0]?.init?.method).toBe('POST');
expect((calls[0]?.init?.headers as Record<string, string>)['Content-Type']).toBe('application/json');
expect(calls[0]?.init?.body).toBe(JSON.stringify(payload));
expect(calls[0]?.init?.keepalive).toBe(false);
});
it('uses the beacon for a final batch and never throws', async () => {
const beacons: { url: string; type: string }[] = [];
const transport = httpUiLogTransport({
beacon: (url, body) => {
beacons.push({ url, type: body.type });
return true;
},
fetch: (() => {
throw new Error('must not be used for a final batch');
}) as unknown as typeof globalThis.fetch,
});
expect(await transport.send({ page: '/', session: 's', entries: [] }, true)).toBe(true);
expect(beacons).toEqual([{ url: '/api/ui-log', type: 'application/json' }]);
const failing = httpUiLogTransport({
fetch: (() => Promise.reject(new Error('down'))) as unknown as typeof globalThis.fetch,
});
expect(await failing.send({ page: '/', session: 's', entries: [] }, false)).toBe(false);
});
});
+224
View File
@@ -0,0 +1,224 @@
/**
* Wiring the frontend log into a page: console, window, promises, clients.
*
* Four feeds, and each one is a place where an error would otherwise stop:
*
* 1. **The console.** Every console method still prints, exactly as before;
* the wrapper calls the original first and records second. Arguments
* are rendered the way a reader would want them in a file: strings as
* they are, an Error as name, message and stack, anything else as JSON
* with cycles and length held in check.
* 2. **Uncaught errors** (`window.onerror` as the `error` event), with file,
* line and column when the browser has them.
* 3. **Unhandled promise rejections.**
* 4. **Caught request failures**, announced by the /rpc and /api clients
* through the observer seam (provider/error-observer.ts). These are the
* ones the panels show as a sentence and the console never sees.
*
* The buffer is flushed with `final` on pagehide and when the tab is hidden,
* so the last lines of a session reach the file (ui-log-transport.ts uses a
* beacon for that). A re-entrancy guard makes sure nothing recorded from
* inside a record call comes back around.
*
* The first entry of a session says which page, which build and which
* browser, because a log file that holds several sessions needs a line
* where each one starts.
*/
import { observeErrors } from '../provider/error-observer';
import { UiLogBuffer } from './ui-log';
import type { UiLogLevel, UiLogTransport } from './ui-log';
import { httpUiLogTransport } from './ui-log-transport';
import { ATLAS_BUILD_SUFFIX, ATLAS_VERSION } from './build-info';
export interface InstallUiLogOptions {
target?: Window;
console?: Console;
transport?: UiLogTransport;
buffer?: UiLogBuffer;
session?: string;
}
export interface UiLogHandle {
buffer: UiLogBuffer;
session: string;
uninstall(): void;
}
const LEVELS: readonly UiLogLevel[] = ['debug', 'log', 'info', 'warn', 'error'];
/** The cap on a rendered console argument; the field cap applies after. */
const ARG_MAX = 2000;
/** JSON for a console argument that is not a string or an Error. */
export function safeStringify(value: unknown, max = ARG_MAX): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'bigint') {
return `${value.toString()}n`;
}
if (typeof value === 'function') {
return `[function ${value.name || 'anonymous'}]`;
}
if (typeof value === 'symbol') {
return value.toString();
}
if (value === undefined) {
return 'undefined';
}
const seen = new WeakSet<object>();
let text: string;
try {
text = JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') {
return `${entry.toString()}n`;
}
if (typeof entry === 'object' && entry !== null) {
if (seen.has(entry)) {
return '[circular]';
}
seen.add(entry);
}
return entry;
}) ?? String(value);
} catch {
text = String(value);
}
return text.length > max ? `${text.slice(0, max)} [cut at ${max}]` : text;
}
/** One line for a console call, plus the first stack among its arguments. */
export function describeArgs(args: readonly unknown[]): { message: string; stack?: string } {
const parts: string[] = [];
let stack: string | undefined;
for (const arg of args) {
if (arg instanceof Error) {
parts.push(`${arg.name}: ${arg.message}`);
if (stack === undefined && typeof arg.stack === 'string' && arg.stack.length > 0) {
stack = arg.stack;
}
} else {
parts.push(safeStringify(arg));
}
}
const message = parts.join(' ');
return stack === undefined ? { message } : { message, stack };
}
/** What a rejected promise rejected with, as message and stack. */
export function describeReason(reason: unknown): { message: string; stack?: string } {
if (reason instanceof Error) {
const stack = typeof reason.stack === 'string' && reason.stack.length > 0 ? reason.stack : undefined;
const message = `${reason.name}: ${reason.message}`;
return stack === undefined ? { message } : { message, stack };
}
return { message: safeStringify(reason) };
}
function newSessionId(): string {
const cryptoApi = (globalThis as { crypto?: Crypto }).crypto;
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') {
return cryptoApi.randomUUID().slice(0, 8);
}
return Math.random().toString(16).slice(2, 10);
}
export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
const target = options.target ?? window;
const con = options.console ?? console;
const session = options.session ?? newSessionId();
const page = `${target.location.pathname}${target.location.search}`;
const buffer = options.buffer
?? new UiLogBuffer({ page, session, transport: options.transport ?? httpUiLogTransport() });
let recording = false;
const guarded = (fn: () => void): void => {
if (recording) {
return;
}
recording = true;
try {
fn();
} catch {
/* Recording must never break the call that was being recorded. */
} finally {
recording = false;
}
};
const originals = new Map<UiLogLevel, (...args: unknown[]) => void>();
for (const level of LEVELS) {
const original = con[level] as (...args: unknown[]) => void;
originals.set(level, original);
con[level] = (...args: unknown[]): void => {
original.apply(con, args);
guarded(() => {
const described = describeArgs(args);
buffer.record(level, 'console', described.message,
described.stack === undefined ? {} : { stack: described.stack });
});
};
}
const onError = (event: ErrorEvent): void => {
guarded(() => {
const described = describeReason(event.error);
buffer.record('error', 'window', event.message.length > 0 ? event.message : described.message, {
stack: described.stack,
url: event.filename,
line: event.lineno,
col: event.colno,
});
});
};
const onRejection = (event: PromiseRejectionEvent): void => {
guarded(() => {
const described = describeReason(event.reason);
buffer.record('error', 'promise', described.message,
described.stack === undefined ? {} : { stack: described.stack });
});
};
const onHide = (): void => {
void buffer.flush(true);
};
const onVisibility = (): void => {
if (target.document.visibilityState === 'hidden') {
void buffer.flush(true);
}
};
const stopObserving = observeErrors((report) => {
guarded(() => {
buffer.record(report.level, report.source, report.message, {
detail: report.detail,
stack: report.stack,
});
});
});
target.addEventListener('error', onError);
target.addEventListener('unhandledrejection', onRejection);
target.addEventListener('pagehide', onHide);
target.document.addEventListener('visibilitychange', onVisibility);
const userAgent = (target.navigator as Navigator | undefined)?.userAgent ?? '';
buffer.record('info', 'ui-log', `session ${session} started on ${page}`, {
detail: `build ${ATLAS_VERSION}${ATLAS_BUILD_SUFFIX.length > 0 ? `-${ATLAS_BUILD_SUFFIX}` : ''}; ${userAgent}`,
url: target.location.href,
});
return {
buffer,
session,
uninstall(): void {
for (const [level, original] of originals) {
con[level] = original;
}
target.removeEventListener('error', onError);
target.removeEventListener('unhandledrejection', onRejection);
target.removeEventListener('pagehide', onHide);
target.document.removeEventListener('visibilitychange', onVisibility);
stopObserving();
},
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* The HTTP leg of the frontend log: POST /api/ui-log, same origin.
*
* Two ways to send, chosen by the buffer: an ordinary fetch while the page
* lives, and `navigator.sendBeacon` when it is being closed, because a fetch
* started from pagehide is cancelled with the page while a beacon is handed
* to the browser and survives it. Both carry `application/json`, which the
* server requires on every POST under /api (the same header the projects
* panel sends); the beacon sets it through the Blob's type.
*
* No error is thrown from here and nothing is written to the console: a
* transport that logged its own failures would feed the buffer it serves.
*/
import { UI_LOG_ROUTE } from './ui-log';
import type { UiLogPayload, UiLogTransport } from './ui-log';
export interface HttpUiLogTransportOptions {
/** Origin of the server, no trailing slash. Empty means same origin. */
base?: string;
fetch?: typeof globalThis.fetch;
/** Replaceable beacon so a test sees what the page sends on its way out. */
beacon?: (url: string, body: Blob) => boolean;
}
function defaultBeacon(): ((url: string, body: Blob) => boolean) | undefined {
const nav = (globalThis as { navigator?: Navigator }).navigator;
if (nav === undefined || typeof nav.sendBeacon !== 'function') {
return undefined;
}
return (url, body) => nav.sendBeacon(url, body);
}
export function httpUiLogTransport(options: HttpUiLogTransportOptions = {}): UiLogTransport {
const url = `${options.base ?? ''}${UI_LOG_ROUTE}`;
return {
async send(payload: UiLogPayload, final: boolean): Promise<boolean> {
const body = JSON.stringify(payload);
if (final) {
const beacon = options.beacon ?? defaultBeacon();
if (beacon !== undefined) {
try {
return beacon(url, new Blob([body], { type: 'application/json' }));
} catch {
return false;
}
}
}
const doFetch = options.fetch ?? globalThis.fetch;
try {
const response = await doFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: final,
});
return response.ok;
} catch {
return false;
}
},
};
}
+163
View File
@@ -0,0 +1,163 @@
/*
* The frontend log buffer: what it batches, what it caps, what it drops and
* says it dropped, and how it behaves while the server does not answer.
* Timers and the clock are handed in, so nothing here waits.
*/
import { describe, expect, it } from 'vitest';
import { capField, UI_LOG_FIELD_MAX, UiLogBuffer } from './ui-log';
import type { UiLogPayload, UiLogTransport } from './ui-log';
interface Timer {
fn: () => void;
ms: number;
cancelled: boolean;
}
function harness(answers: () => boolean = () => true) {
const posts: { payload: UiLogPayload; final: boolean }[] = [];
const timers: Timer[] = [];
const transport: UiLogTransport = {
send: async (payload, final) => {
posts.push({ payload, final });
return answers();
},
};
let tick = 0;
const buffer = new UiLogBuffer({
page: '/?project=demo',
session: 'abc',
transport,
now: () => new Date(Date.UTC(2026, 8, 8, 10, 0, tick++)),
schedule: (fn, ms) => {
const timer: Timer = { fn, ms, cancelled: false };
timers.push(timer);
return timer;
},
cancel: (handle) => {
(handle as Timer).cancelled = true;
},
bufferMax: 5,
batchMax: 3,
});
/** Fire the newest live timer, as the browser would when its time comes. */
const fire = async (): Promise<void> => {
const live = timers.filter((timer) => !timer.cancelled);
const timer = live[live.length - 1];
if (timer === undefined) {
throw new Error('no timer armed');
}
timer.cancelled = true;
timer.fn();
await Promise.resolve();
await Promise.resolve();
};
return { buffer, posts, timers, fire };
}
describe('UiLogBuffer', () => {
it('queues entries with a sequence and a timestamp, and posts them as one batch on the timer', async () => {
const { buffer, posts, timers, fire } = harness();
buffer.record('error', 'rpc', 'get_code_snippet returned no source', { detail: 'HTTP 200' });
buffer.record('log', 'console', 'galaxy ready');
expect(timers.length).toBe(1);
expect(timers[0]?.ms).toBe(1500);
expect(posts.length).toBe(0);
await fire();
expect(posts.length).toBe(1);
expect(posts[0]?.final).toBe(false);
expect(posts[0]?.payload.page).toBe('/?project=demo');
expect(posts[0]?.payload.session).toBe('abc');
expect(posts[0]?.payload.entries).toEqual([
{
ts: '2026-09-08T10:00:00.000Z',
seq: 1,
level: 'error',
source: 'rpc',
message: 'get_code_snippet returned no source',
detail: 'HTTP 200',
},
{ ts: '2026-09-08T10:00:01.000Z', seq: 2, level: 'log', source: 'console', message: 'galaxy ready' },
]);
expect(buffer.stats()).toEqual({ queued: 0, sent: 2, dropped: 0, failedPosts: 0 });
});
it('sends more than one batch when the queue is longer than a batch', async () => {
const { buffer, posts, fire } = harness();
for (let i = 0; i < 5; i++) {
buffer.record('info', 'console', `line ${i}`);
}
await fire();
expect(posts.length).toBe(1);
expect(posts[0]?.payload.entries.map((entry) => entry.message)).toEqual(['line 0', 'line 1', 'line 2']);
await fire();
expect(posts.length).toBe(2);
expect(posts[1]?.payload.entries.map((entry) => entry.message)).toEqual(['line 3', 'line 4']);
});
it('caps every text field at the shared limit and marks the cut', () => {
const long = 'x'.repeat(UI_LOG_FIELD_MAX + 10);
expect(capField(long)).toBe(`${'x'.repeat(UI_LOG_FIELD_MAX)} [cut at ${UI_LOG_FIELD_MAX}]`);
expect(capField('short')).toBe('short');
});
it('drops the oldest beyond the cap and says so at the head of the next batch', async () => {
const { buffer, posts, fire } = harness();
for (let i = 0; i < 7; i++) {
buffer.record('info', 'console', `line ${i}`);
}
expect(buffer.stats().dropped).toBe(2);
await fire();
const first = posts[0]?.payload.entries[0];
expect(first?.level).toBe('warn');
expect(first?.source).toBe('ui-log');
expect(first?.message).toContain('2 entries were dropped');
expect(posts[0]?.payload.entries.slice(1).map((entry) => entry.message)).toEqual(['line 2', 'line 3', 'line 4']);
});
it('keeps a refused batch, backs off, and resends it in order when the server answers', async () => {
let ok = false;
const { buffer, posts, timers, fire } = harness(() => ok);
buffer.record('error', 'window', 'boom');
await fire();
expect(posts.length).toBe(1);
expect(buffer.stats()).toEqual({ queued: 1, sent: 0, dropped: 0, failedPosts: 1 });
const retry = timers[timers.length - 1];
expect(retry?.ms).toBe(2000);
buffer.record('log', 'console', 'after');
await fire();
expect(posts.length).toBe(2);
expect(timers[timers.length - 1]?.ms).toBe(5000);
ok = true;
await fire();
expect(posts.length).toBe(3);
expect(posts[2]?.payload.entries.map((entry) => entry.message)).toEqual(['boom', 'after']);
expect(buffer.stats()).toEqual({ queued: 0, sent: 2, dropped: 0, failedPosts: 2 });
});
it('sends the rest with final on flush(true) and arms nothing after it', async () => {
const { buffer, posts, timers } = harness();
buffer.record('warn', 'api', '/api/tree answered with HTTP 500');
await buffer.flush(true);
expect(posts.length).toBe(1);
expect(posts[0]?.final).toBe(true);
expect(timers.every((timer) => timer.cancelled)).toBe(true);
});
it('does not throw when the transport does', async () => {
const throwing: UiLogTransport = {
send: async () => {
throw new Error('no network');
},
};
const buffer = new UiLogBuffer({ page: '/', session: 's', transport: throwing, schedule: () => 0, cancel: () => undefined });
buffer.record('info', 'console', 'x');
await expect(buffer.flush()).resolves.toBeUndefined();
expect(buffer.stats().failedPosts).toBe(1);
expect(buffer.stats().queued).toBe(1);
});
});
+269
View File
@@ -0,0 +1,269 @@
/**
* The frontend's log, on its way to the server.
*
* The browser console is where this window has always written, and it stays
* that way: the console is the reader's view. But the console lives and dies
* with the tab. Nothing there reaches the process that serves the page, so a
* reader who saw a panel fail had nothing to hand over, and an agent looking
* into the failure had nothing to read. This buffer is the second copy: every
* console call, every uncaught error, every rejected promise and every failed
* /rpc or /api request is queued here and posted to POST /api/ui-log, which
* appends it to <cache_dir>/logs/ui.log on the server (src/ui/http_server.c).
* The file is what a bug report attaches and what `tail -f` reads.
*
* The rules of the buffer, and why each is there:
*
* - **Batched, not chatty.** Entries wait up to UI_LOG_FLUSH_MS and go out
* in groups of at most UI_LOG_BATCH_MAX. A page that logs once per frame
* must not turn into a request per frame.
* - **Bounded.** UI_LOG_BUFFER_MAX entries wait while the server does not
* answer; the oldest are dropped first, and the next batch that gets
* through starts with a line that says how many were lost. A log that
* silently forgets is worse than one that says it forgot.
* - **Backs off.** A failed post is retried on a growing delay
* (UI_LOG_BACKOFF_MS). An unreachable server is a fact the reader sees in
* the header chip; hammering it from here would add nothing.
* - **Never loud.** Nothing in this file writes to the console. The console
* wrapper (ui-log-install.ts) feeds this buffer, so a line written from
* here would come straight back.
* - **Capped fields.** Every text field is cut at UI_LOG_FIELD_MAX
* characters, the same cap the server applies; a cut is marked so nobody
* reads a truncated stack as a complete one.
*
* Time and timers are injectable so a test runs in one tick.
*/
export type UiLogLevel = 'debug' | 'log' | 'info' | 'warn' | 'error';
export interface UiLogEntry {
/** When the entry was recorded on the page, ISO 8601. */
ts: string;
/** Position in this page's sequence, from 1. Gaps mean dropped entries. */
seq: number;
level: UiLogLevel;
/** Who recorded it: console, window, promise, rpc, api, reader, ui-log. */
source: string;
message: string;
detail?: string;
stack?: string;
url?: string;
line?: number;
col?: number;
}
export interface UiLogPayload {
page: string;
session: string;
entries: UiLogEntry[];
}
export interface UiLogTransport {
/**
* Deliver one batch. `final` means the page is going away and the answer
* will not be waited for. Resolves true when the server accepted it.
*/
send(payload: UiLogPayload, final: boolean): Promise<boolean>;
}
export interface UiLogExtra {
detail?: string;
stack?: string;
url?: string;
line?: number;
col?: number;
}
export interface UiLogStats {
queued: number;
sent: number;
dropped: number;
failedPosts: number;
}
/** The route the transport posts to; the server tails the same path on GET. */
export const UI_LOG_ROUTE = '/api/ui-log';
/** The cap per text field, the server's as well. */
export const UI_LOG_FIELD_MAX = 4096;
export const UI_LOG_BATCH_MAX = 25;
export const UI_LOG_FLUSH_MS = 1500;
export const UI_LOG_BUFFER_MAX = 200;
export const UI_LOG_BACKOFF_MS: readonly number[] = [2000, 5000, 15000, 60000];
export interface UiLogOptions {
page: string;
session: string;
transport: UiLogTransport;
now?: () => Date;
schedule?: (fn: () => void, ms: number) => unknown;
cancel?: (handle: unknown) => void;
flushMs?: number;
batchMax?: number;
bufferMax?: number;
backoffMs?: readonly number[];
}
/** Cut a text field at the shared cap and say so. */
export function capField(text: string, max = UI_LOG_FIELD_MAX): string {
return text.length > max ? `${text.slice(0, max)} [cut at ${max}]` : text;
}
export class UiLogBuffer {
private readonly queue: UiLogEntry[] = [];
private seq = 0;
private sent = 0;
private dropped = 0;
private droppedSinceFlush = 0;
private failedPosts = 0;
private timer: unknown = undefined;
private inFlight = false;
private backoffStep = 0;
private readonly now: () => Date;
private readonly schedule: (fn: () => void, ms: number) => unknown;
private readonly cancel: (handle: unknown) => void;
private readonly flushMs: number;
private readonly batchMax: number;
private readonly bufferMax: number;
private readonly backoffMs: readonly number[];
constructor(private readonly options: UiLogOptions) {
this.now = options.now ?? (() => new Date());
this.schedule = options.schedule ?? ((fn, ms) => setTimeout(fn, ms));
this.cancel = options.cancel ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
this.flushMs = options.flushMs ?? UI_LOG_FLUSH_MS;
this.batchMax = options.batchMax ?? UI_LOG_BATCH_MAX;
this.bufferMax = options.bufferMax ?? UI_LOG_BUFFER_MAX;
this.backoffMs = options.backoffMs ?? UI_LOG_BACKOFF_MS;
}
get session(): string {
return this.options.session;
}
get page(): string {
return this.options.page;
}
/** Queue one entry. Never throws and never writes to the console. */
record(level: UiLogLevel, source: string, message: string, extra: UiLogExtra = {}): void {
const entry: UiLogEntry = {
ts: this.now().toISOString(),
seq: ++this.seq,
level,
source: source.slice(0, 64),
message: capField(message),
};
if (extra.detail !== undefined && extra.detail.length > 0) {
entry.detail = capField(extra.detail);
}
if (extra.stack !== undefined && extra.stack.length > 0) {
entry.stack = capField(extra.stack);
}
if (extra.url !== undefined && extra.url.length > 0) {
entry.url = capField(extra.url, 1024);
}
if (typeof extra.line === 'number' && Number.isFinite(extra.line)) {
entry.line = extra.line;
}
if (typeof extra.col === 'number' && Number.isFinite(extra.col)) {
entry.col = extra.col;
}
this.push(entry);
this.arm(this.flushMs);
}
stats(): UiLogStats {
return {
queued: this.queue.length,
sent: this.sent,
dropped: this.dropped,
failedPosts: this.failedPosts,
};
}
/**
* Send what is queued, one batch now and the rest on the timer. With
* `final` the page is going away: the transport is told so, and nothing
* is rescheduled.
*/
async flush(final = false): Promise<void> {
if (this.timer !== undefined) {
this.cancel(this.timer);
this.timer = undefined;
}
if (this.inFlight && !final) {
return;
}
if (this.queue.length === 0) {
return;
}
const batch = this.queue.splice(0, this.batchMax);
if (this.droppedSinceFlush > 0) {
const lost = this.droppedSinceFlush;
this.droppedSinceFlush = 0;
batch.unshift({
ts: this.now().toISOString(),
seq: ++this.seq,
level: 'warn',
source: 'ui-log',
message: `${lost} entries were dropped before this batch: the page keeps ${this.bufferMax} while the server does not answer`,
});
}
const payload: UiLogPayload = {
page: this.options.page,
session: this.options.session,
entries: batch,
};
this.inFlight = true;
let accepted = false;
try {
accepted = await this.options.transport.send(payload, final);
} catch {
accepted = false;
} finally {
this.inFlight = false;
}
if (accepted) {
this.sent += batch.length;
this.backoffStep = 0;
if (this.queue.length > 0 && !final) {
this.arm(0);
}
return;
}
this.failedPosts += 1;
// Put the batch back in front so order is kept, then trim to the cap
// from the oldest end, the same way record() does.
this.queue.unshift(...batch);
while (this.queue.length > this.bufferMax) {
this.queue.shift();
this.dropped += 1;
this.droppedSinceFlush += 1;
}
if (!final) {
const step = Math.min(this.backoffStep, this.backoffMs.length - 1);
const wait = this.backoffMs[step] ?? this.flushMs;
this.backoffStep += 1;
this.arm(wait);
}
}
private push(entry: UiLogEntry): void {
if (this.queue.length >= this.bufferMax) {
this.queue.shift();
this.dropped += 1;
this.droppedSinceFlush += 1;
}
this.queue.push(entry);
}
private arm(ms: number): void {
if (this.timer !== undefined || this.inFlight) {
return;
}
this.timer = this.schedule(() => {
this.timer = undefined;
void this.flush();
}, ms);
}
}
+9
View File
@@ -1081,6 +1081,15 @@ export const messages = {
logsSource: (shown: number, total: number): string => `read from GET /api/logs: ${shown} of ${total} lines`,
logsEmpty: 'the log ring is empty',
logsError: (detail: string): string => `the log did not arrive: ${detail}`,
uiLogTitle: 'Frontend log',
uiLogSource: (shown: number, total: number, path: string): string =>
`read from GET /api/ui-log: ${shown} of ${total} lines of ${path}`,
uiLogPrevious: (path: string): string => `rotated once; the lines before these are in ${path}`,
uiLogAbout:
'This page sends its console output, uncaught errors and failed requests to that file as '
+ 'they happen. Attach the file to a bug report; an agent can read it with the same route.',
uiLogEmpty: 'nothing recorded yet',
uiLogError: (detail: string): string => `the frontend log did not arrive: ${detail}`,
reload: 'reload',
},
+6
View File
@@ -1,12 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { installUiLog } from './app/ui-log-install';
// Erst die Tokens, dann das Chrome, das sie benutzt. Die Reihenfolge ist keine
// Kosmetik: eine Regel, die eine noch nicht definierte Variable liest, faellt
// still auf ihren Initialwert zurueck.
import './styles/tokens.css';
import './styles/terminal.css';
// Before anything else can fail: the console keeps printing, and from here
// on a copy of every line, every uncaught error and every failed request
// reaches the server's file (app/ui-log.ts, GET /api/ui-log).
installUiLog();
const container = document.getElementById('root');
if (container === null) {
throw new Error('#root fehlt in index.html');
@@ -62,6 +62,17 @@ function fakeSource(jobs: () => IndexJob[] = () => []): ProjectsSource & { calls
adr: record('adr', async () => ({ hasAdr: true, content: '# old record', updatedAt: '2026-09-05 10:00' })),
saveAdr: record('saveAdr', async () => undefined),
logs: record('logs', async () => ({ lines: ['line one', 'line two'], total: 40 })),
uiLogTail: record('uiLogTail', async () => ({
path: '/cache/logs/ui.log',
previousPath: '',
sizeBytes: 512,
partial: false,
lines: [
'{"received":"2026-09-08T10:00:00Z","ts":"2026-09-08T10:00:00.000Z","level":"error","source":"rpc","message":"get_code_snippet: HTTP 500","detail":"oom"}',
'not json at all',
],
total: 2,
})),
processes: record('processes', async () => ({
selfPid: 42,
selfRssMb: 120.5,
@@ -308,6 +319,27 @@ describe('the server block', () => {
expect(container.textContent).toContain(messages.projects.logsSource(2, 40));
});
it('shows the frontend log as sentences and names the file to attach', async () => {
await render();
expect(byTestId('atlas-projects-ui-log-source')?.textContent)
.toBe(messages.projects.uiLogSource(2, 2, '/cache/logs/ui.log'));
expect(byTestId('atlas-projects-ui-log')?.textContent).toBe(
'2026-09-08T10:00:00.000Z error rpc: get_code_snippet: HTTP 500 (oom)\nnot json at all',
);
expect(container.textContent).toContain(messages.projects.uiLogAbout);
expect(container.textContent).not.toContain(messages.projects.uiLogPrevious(''));
});
it('says when the frontend log did not arrive, and still shows the rest', async () => {
const source = fakeSource();
source.uiLogTail = async () => {
throw new AtlasApiError('/api/ui-log', 500, '/api/ui-log antwortete mit HTTP 500: no');
};
await render({ source });
expect(container.textContent).toContain(messages.projects.uiLogError('/api/ui-log antwortete mit HTTP 500: no'));
expect(byTestId('atlas-projects-logs')?.textContent).toBe('line one\nline two');
});
it('asks again on reload', async () => {
const props = await render();
const source = props.source as ReturnType<typeof fakeSource>;
@@ -315,6 +347,7 @@ describe('the server block', () => {
await click(byTestId('atlas-projects-server-reload'));
expect(source.calls['logs']?.length).toBe(before + 1);
expect(source.calls['processes']?.length).toBe(before + 1);
expect(source.calls['uiLogTail']?.length).toBe(before + 1);
});
});
+38 -2
View File
@@ -12,7 +12,8 @@
* Three rules shape it, and they are the rules of the rest of this surface:
*
* 1. **Every block names its source.** The list says it comes from
* list_projects, the jobs say they come from /api/index-status, and a
* list_projects, the jobs say they come from /api/index-status, the
* frontend log names the file it is read from (GET /api/ui-log), and a
* number the server did not send is shown as not sent, never as zero.
* 2. **A control that cannot act is not on the screen.** The index button
* appears once a path and a name are there; a project whose root the
@@ -34,7 +35,7 @@ import type { JSX, ReactNode } from 'react';
import { AtlasApiError } from '../app/atlas-api';
import { messages } from '../i18n/messages';
import type { ProjectEntry } from '../provider/rpc-schemas';
import { anyIndexing, childPath, finishedSince, megabytes, projectNameFor } from './projects-model';
import { anyIndexing, childPath, finishedSince, megabytes, projectNameFor, uiLogLineText } from './projects-model';
import type {
AdrRecord,
BrowseLevel,
@@ -43,6 +44,7 @@ import type {
LogTail,
ProcessReport,
ProjectHealth,
UiLogTail,
} from './projects-model';
const text = messages.projects;
@@ -69,6 +71,7 @@ export interface ProjectsSource {
saveAdr(project: string, content: string): Promise<void>;
logs(lines: number): Promise<LogTail>;
processes(): Promise<ProcessReport>;
uiLogTail(lines: number): Promise<UiLogTail>;
}
export interface ProjectsPanelProps {
@@ -170,6 +173,8 @@ export default function ProjectsPanel(props: ProjectsPanelProps): JSX.Element {
const [processesError, setProcessesError] = useState('');
const [logs, setLogs] = useState<LogTail | undefined>(undefined);
const [logsError, setLogsError] = useState('');
const [uiLog, setUiLog] = useState<UiLogTail | undefined>(undefined);
const [uiLogError, setUiLogError] = useState('');
useEffect(() => {
mounted.current = true;
@@ -256,6 +261,17 @@ export default function ProjectsPanel(props: ProjectsPanelProps): JSX.Element {
setLogsError(text.logsError(describeError(error)));
}
}
try {
const tail = await source.uiLogTail(LOG_LINES);
if (mounted.current) {
setUiLog(tail);
setUiLogError('');
}
} catch (error) {
if (mounted.current) {
setUiLogError(text.uiLogError(describeError(error)));
}
}
}, [source]);
useEffect(() => {
@@ -799,6 +815,26 @@ export default function ProjectsPanel(props: ProjectsPanelProps): JSX.Element {
)}
</>
)}
<h4 className="atlas-projects-subtitle-inline">{text.uiLogTitle}</h4>
<p className="atlas-projects-note">{text.uiLogAbout}</p>
{uiLogError.length > 0 && <p className="atlas-projects-notice">{uiLogError}</p>}
{uiLog !== undefined && (
<>
<p className="atlas-projects-source" data-testid="atlas-projects-ui-log-source">
{text.uiLogSource(uiLog.lines.length, uiLog.total, uiLog.path)}
</p>
{uiLog.previousPath.length > 0 && (
<p className="atlas-projects-note">{text.uiLogPrevious(uiLog.previousPath)}</p>
)}
{uiLog.lines.length === 0 ? (
<p className="atlas-projects-note">{text.uiLogEmpty}</p>
) : (
<pre className="atlas-projects-logs" data-testid="atlas-projects-ui-log">
{uiLog.lines.map(uiLogLineText).join('\n')}
</pre>
)}
</>
)}
<div className="atlas-projects-row-actions">
<button
type="button"
@@ -20,6 +20,8 @@ import {
readIndexStarted,
readLogs,
readProcesses,
readUiLogTail,
uiLogLineText,
} from './projects-model';
describe('readIndexJobs', () => {
@@ -140,6 +142,24 @@ describe('readAdr', () => {
});
});
describe('readUiLogTail and uiLogLineText', () => {
it('reads the tail with defaults for what the server did not send', () => {
expect(readUiLogTail({ path: '/c/ui.log', lines: ['a'], size_bytes: 9, partial: false }))
.toEqual({ path: '/c/ui.log', previousPath: '', sizeBytes: 9, partial: false, lines: ['a'], total: 1 });
expect(readUiLogTail('nonsense'))
.toEqual({ path: '', previousPath: '', sizeBytes: 0, partial: false, lines: [], total: 0 });
});
it('turns a JSON line into a sentence and leaves anything else alone', () => {
expect(uiLogLineText('{"received":"2026-09-08T10:00:00Z","level":"warn","source":"api","message":"/api/tree: HTTP 423","detail":"busy"}'))
.toBe('2026-09-08T10:00:00Z warn api: /api/tree: HTTP 423 (busy)');
expect(uiLogLineText('{"ts":"2026-09-08T10:00:01.000Z","received":"later","level":"log","source":"console","message":"ready"}'))
.toBe('2026-09-08T10:00:01.000Z log console: ready');
expect(uiLogLineText('plain text')).toBe('plain text');
expect(uiLogLineText('[1,2]')).toBe('[1,2]');
});
});
describe('readLogs and readProcesses', () => {
it('reads the log tail and its total', () => {
expect(readLogs({ lines: ['a', 'b'], total: 400 })).toEqual({ lines: ['a', 'b'], total: 400 });
+53
View File
@@ -19,6 +19,7 @@
* GET /api/adr?project= {has_adr, content?, updated_at?}
* POST /api/adr {saved: true}
* GET /api/logs?lines= {lines[], total}
* GET /api/ui-log?lines= {path, previous_path?, size_bytes, partial, lines[], total}
* GET /api/processes {self_pid, self_rss_mb, ..., processes[]}
*/
@@ -194,6 +195,58 @@ export interface ServerProcess {
isSelf: boolean;
}
/** The tail of the frontend log file the server keeps (POST /api/ui-log). */
export interface UiLogTail {
/** The file on the server; what a bug report attaches. */
path: string;
/** The rotated file before it, when there is one. Empty otherwise. */
previousPath: string;
sizeBytes: number;
/** True when the lines do not start at the first line of the file. */
partial: boolean;
/** One JSON object per line, as written by the server. */
lines: string[];
total: number;
}
export function readUiLogTail(raw: unknown): UiLogTail {
const record = isRecord(raw) ? raw : {};
const lines = strings(record['lines']);
return {
path: text(record['path']),
previousPath: text(record['previous_path']),
sizeBytes: optionalNumber(record['size_bytes']) ?? 0,
partial: record['partial'] === true,
lines,
total: optionalNumber(record['total']) ?? lines.length,
};
}
/**
* One line of the frontend log as the panel shows it: time, level, source
* and message, with the detail in brackets. The file keeps the JSON; a
* reader scanning the panel wants the sentence. A line that is not JSON is
* shown as it is.
*/
export function uiLogLineText(line: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
return line;
}
if (!isRecord(parsed)) {
return line;
}
const when = text(parsed['ts']).length > 0 ? text(parsed['ts']) : text(parsed['received']);
const level = text(parsed['level']);
const source = text(parsed['source']);
const message = text(parsed['message']);
const detail = text(parsed['detail']);
const head = [when, level, source.length > 0 ? `${source}:` : ''].filter((part) => part.length > 0).join(' ');
return `${head} ${message}${detail.length > 0 ? ` (${detail})` : ''}`.trim();
}
export interface ProcessReport {
selfPid: number;
selfRssMb: number;
@@ -0,0 +1,116 @@
/*
* The observer seam: a failing /rpc or /api call is announced before it is
* thrown, with a level that tells a refused tool from a broken server, and
* a listener that fails does not make a second error out of the first.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { AtlasApi } from '../app/atlas-api';
import { hasErrorObservers, observeErrors, reportError } from './error-observer';
import type { ErrorReport } from './error-observer';
import { callToolJson, callToolText } from './rpc-transport';
let stop: (() => void) | undefined;
afterEach(() => {
stop?.();
stop = undefined;
});
function listen(): ErrorReport[] {
const reports: ErrorReport[] = [];
stop = observeErrors((report) => reports.push(report));
return reports;
}
function replying(status: number, body: string): typeof globalThis.fetch {
return (() => Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: '',
text: () => Promise.resolve(body),
})) as unknown as typeof globalThis.fetch;
}
describe('reportError', () => {
it('reaches every listener, survives one that throws, and is silent without any', () => {
expect(hasErrorObservers()).toBe(false);
reportError({ source: 'ui', level: 'info', message: 'nobody listens' });
const seen: string[] = [];
const stopFirst = observeErrors(() => {
throw new Error('a bad listener');
});
stop = observeErrors((report) => seen.push(report.message));
expect(hasErrorObservers()).toBe(true);
reportError({ source: 'ui', level: 'warn', message: 'both' });
expect(seen).toEqual(['both']);
stopFirst();
});
});
describe('the /rpc client announces', () => {
it('a refused tool as info, with the tool name in the message', async () => {
const reports = listen();
await expect(callToolText('index_repository', {}, {
fetch: replying(403, '{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"not on /rpc"}}'),
})).rejects.toThrow();
expect(reports.length).toBe(1);
expect(reports[0]?.source).toBe('rpc');
expect(reports[0]?.level).toBe('info');
expect(reports[0]?.message).toContain('/rpc index_repository: HTTP 403');
});
it('a broken server as error, with the body as detail', async () => {
const reports = listen();
await expect(callToolText('query_graph', {}, { fetch: replying(500, 'oom') })).rejects.toThrow();
expect(reports[0]?.level).toBe('error');
expect(reports[0]?.detail).toBe('oom');
});
it('a tool that said no as warn, and an unreadable answer as error', async () => {
const reports = listen();
await expect(callToolText('get_code_snippet', {}, {
fetch: replying(200, '{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"no such symbol"}],"isError":true}}'),
})).rejects.toThrow();
await expect(callToolJson('list_projects', {}, {
fetch: replying(200, '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"not json"}]}}'),
})).rejects.toThrow();
expect(reports.map((report) => report.level)).toEqual(['warn', 'error']);
expect(reports[1]?.message).toContain('/rpc list_projects');
});
it('an unreachable server as error, and an aborted call not at all', async () => {
const reports = listen();
await expect(callToolText('search_graph', {}, {
fetch: (() => Promise.reject(new TypeError('Failed to fetch'))) as unknown as typeof globalThis.fetch,
})).rejects.toThrow();
const aborted = new Error('The user aborted a request.');
aborted.name = 'AbortError';
await expect(callToolText('search_graph', {}, {
fetch: (() => Promise.reject(aborted)) as unknown as typeof globalThis.fetch,
})).rejects.toThrow();
expect(reports.length).toBe(1);
expect(reports[0]?.message).toBe('/rpc search_graph: Failed to fetch');
});
});
describe('the /api client announces', () => {
it('a refused route as warn, a broken one as error, an unreachable one as error', async () => {
const reports = listen();
await expect(new AtlasApi({ fetch: replying(423, '{"error":"busy"}') }).saveAdr('p', 'x')).rejects.toThrow();
await expect(new AtlasApi({ fetch: replying(500, 'no') }).tree('p')).rejects.toThrow();
await expect(new AtlasApi({
fetch: (() => Promise.reject(new TypeError('Failed to fetch'))) as unknown as typeof globalThis.fetch,
}).processes()).rejects.toThrow();
await expect(new AtlasApi({ fetch: replying(200, 'not json') }).indexJobs()).rejects.toThrow();
expect(reports.map((report) => [report.source, report.level])).toEqual([
['api', 'warn'],
['api', 'error'],
['api', 'error'],
['api', 'error'],
]);
expect(reports[0]?.message).toContain('/api/adr');
expect(reports[0]?.detail).toBe('{"error":"busy"}');
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Where a caught error is announced before it is shown.
*
* The clients of this window turn every failed request into a typed error
* (RpcError in rpc-transport.ts, AtlasApiError in app/atlas-api.ts), and the
* panels catch those and show a sentence. That is right for the reader in
* front of the screen and useless for everyone else: a caught error never
* reaches the console, so it never reached the server's log file either
* (app/ui-log.ts). This seam is the one place the throwing clients announce
* an error before throwing it; the log layer listens, and nothing else has
* to know that it does.
*
* A report is a fact about a request, never a decision: a listener may not
* throw, and a report with no listener is dropped without a trace, which is
* the state of every test that did not install the log layer.
*/
export type ReportSource = 'rpc' | 'api' | 'reader' | 'ui';
export type ReportLevel = 'info' | 'warn' | 'error';
export interface ErrorReport {
source: ReportSource;
level: ReportLevel;
message: string;
/** The response body or the argument that explains the message, cut short. */
detail?: string;
stack?: string;
}
type Listener = (report: ErrorReport) => void;
const listeners = new Set<Listener>();
/** Listen to reports; the returned function stops listening. */
export function observeErrors(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** True while someone listens. The clients skip building a report otherwise. */
export function hasErrorObservers(): boolean {
return listeners.size > 0;
}
/** Announce a report to every listener. Never throws. */
export function reportError(report: ErrorReport): void {
for (const listener of listeners) {
try {
listener(report);
} catch {
/* A listener that fails must not turn one error into two. */
}
}
}
/** The stack of an unknown thrown value, when it has one. */
export function stackOf(error: unknown): string | undefined {
return error instanceof Error && typeof error.stack === 'string' && error.stack.length > 0
? error.stack
: undefined;
}
+48 -1
View File
@@ -11,6 +11,8 @@
* was gerade schiefging.
*/
import { hasErrorObservers, reportError, stackOf } from './error-observer';
/**
* Woran der Aufruf gescheitert ist.
*
@@ -154,6 +156,49 @@ export async function callTool(
name: string,
args: Record<string, unknown> = {},
opts: CallToolOptions = {},
): Promise<McpToolResult> {
try {
return await callToolUnobserved(name, args, opts);
} catch (err) {
announceFailure(name, err);
throw err;
}
}
/**
* Announce a failed call to whoever listens (provider/error-observer.ts)
* before it is thrown. The level tells the cases apart the way the
* surface does: a tool this server does not offer is a fact about the
* server (info), a tool that said no is a fact about the question (warn),
* everything else is a failure (error). An aborted call is the caller's own
* doing and is not announced.
*/
function announceFailure(name: string, err: unknown): void {
if (!hasErrorObservers()) {
return;
}
if (err instanceof Error && err.name === 'AbortError') {
return;
}
if (err instanceof RpcError) {
const level = err.notAllowed ? 'info' : err.kind === 'tool' ? 'warn' : 'error';
reportError({
source: 'rpc',
level,
message: err.message,
detail: err.bodyText,
stack: stackOf(err),
});
return;
}
const message = err instanceof Error ? err.message : String(err);
reportError({ source: 'rpc', level: 'error', message: `/rpc ${name}: ${message}`, stack: stackOf(err) });
}
async function callToolUnobserved(
name: string,
args: Record<string, unknown>,
opts: CallToolOptions,
): Promise<McpToolResult> {
const base = opts.base ?? '';
const doFetch = opts.fetch ?? globalThis.fetch;
@@ -282,11 +327,13 @@ export async function callToolJson<T = unknown>(
try {
return JSON.parse(text) as T;
} catch (err) {
throw new RpcError(
const shape = new RpcError(
'shape',
name,
`/rpc ${name}: Antworttext war kein JSON (${(err as Error).message})`,
{ bodyText: truncate(text) },
);
announceFailure(name, shape);
throw shape;
}
}
+396
View File
@@ -51,6 +51,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifdef _WIN32
#include <windows.h>
#include <process.h>
@@ -410,6 +411,7 @@ static char g_log_ring[LOG_RING_SIZE][LOG_LINE_MAX];
static int g_log_head = 0;
static int g_log_count = 0;
static cbm_mutex_t g_log_mutex;
static cbm_mutex_t g_ui_log_mutex; /* the frontend log file, further down */
enum { CBM_LOG_MUTEX_UNINIT = 0, CBM_LOG_MUTEX_INITING = 1, CBM_LOG_MUTEX_INITED = 2 };
static atomic_int g_log_mutex_init = CBM_LOG_MUTEX_UNINIT;
@@ -424,6 +426,7 @@ void cbm_ui_log_init(void) {
state = CBM_LOG_MUTEX_UNINIT;
if (atomic_compare_exchange_strong(&g_log_mutex_init, &state, CBM_LOG_MUTEX_INITING)) {
cbm_mutex_init(&g_log_mutex);
cbm_mutex_init(&g_ui_log_mutex);
atomic_store(&g_log_mutex_init, CBM_LOG_MUTEX_INITED);
return;
}
@@ -552,6 +555,387 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
free(buf);
}
/* ── Frontend log file ────────────────────────────────────────── */
/*
* POST /api/ui-log — the frontend's console and error stream, on disk.
*
* The browser keeps its own console and nothing there reaches this process,
* so a reader who saw a panel fail had nothing to hand over and an agent
* nothing to read. The UI's logging layer (graph-ui/src/app/ui-log.ts)
* batches console output, uncaught errors, rejected promises and failed
* /rpc and /api calls and posts them here. Each entry becomes one JSON line
* in <cache_dir>/logs/ui.log, beside the daemon's own records, so `tail -f`
* and GET /api/ui-log read the same file and a bug report can attach it
* whole. Warnings and errors are also mirrored into the ring that GET
* /api/logs serves, so the two sides of one failure sit in one list; the
* chatter below that level stays in the file only.
*
* Bounds, all fixed here: 64 KiB per post, 100 entries per post, 4096
* characters per text field (cut on a UTF-8 boundary and marked), and the
* file rotates once to ui.log.1 when it reaches CBM_UI_LOG_ROTATE_BYTES
* (default 5 MiB). Nothing in a post is trusted beyond its shape: every
* string is escaped again on the way out, the level is normalised to a known
* word, and an entry that is not an object is counted as dropped.
*/
#define UI_LOG_BODY_MAX (64 * 1024)
#define UI_LOG_ENTRIES_MAX 100
#define UI_LOG_FIELD_MAX 4096
#define UI_LOG_ROTATE_DEFAULT (5 * 1024 * 1024)
#define UI_LOG_TAIL_DEFAULT_LINES 200
#define UI_LOG_TAIL_MAX_LINES 1000
#define UI_LOG_TAIL_READ_BYTES (256 * 1024)
/* One JSON line: three long fields escaped at up to 6x, plus the short ones. */
#define UI_LOG_LINE_CAP (3 * 6 * UI_LOG_FIELD_MAX + 4096)
bool cbm_ui_log_file_path(char *out, size_t outsz) {
const char *dir = cbm_resolve_cache_dir();
if (!dir || dir[0] == '\0')
dir = cbm_tmpdir();
int n = snprintf(out, outsz, "%s/logs/ui.log", dir);
return n > 0 && (size_t)n < outsz;
}
static int64_t ui_log_rotate_bytes(void) {
const char *env = getenv("CBM_UI_LOG_ROTATE_BYTES");
if (env && env[0] != '\0') {
long long v = atoll(env);
if (v > 0)
return (int64_t)v;
}
return UI_LOG_ROTATE_DEFAULT;
}
typedef struct {
char field[UI_LOG_FIELD_MAX + 32];
char esc[6 * UI_LOG_FIELD_MAX + 64];
char page[6 * 256 + 64];
char session[6 * 128 + 64];
char line[UI_LOG_LINE_CAP];
} ui_log_scratch_t;
/* Copy a text field under a cap, cutting on a UTF-8 boundary and saying so. */
static void ui_log_copy_field(char *out, size_t outsz, const char *in, size_t cap) {
size_t n = strlen(in);
bool cut = false;
if (n > cap) {
n = cap;
cut = true;
while (n > 0 && ((unsigned char)in[n] & 0xC0) == 0x80)
n--;
}
if (n >= outsz)
n = outsz - 1;
memcpy(out, in, n);
out[n] = '\0';
if (cut)
snprintf(out + n, outsz - n, " [cut at %d]", (int)cap);
}
static const char *ui_log_level_name(yyjson_val *v) {
static const char *const known[] = {"debug", "log", "info", "warn", "error"};
const char *s = (v && yyjson_is_str(v)) ? yyjson_get_str(v) : "";
for (size_t i = 0; i < sizeof(known) / sizeof(known[0]); i++) {
if (strcmp(s, known[i]) == 0)
return known[i];
}
return "log";
}
/* Append `,"key":"<escaped value>"` for a string member, or nothing when the
* member is absent or not a string (required ones are written empty). */
static void ui_log_add_str(ui_log_scratch_t *s, int *pos, const char *key, yyjson_val *v,
bool required) {
if (!v || !yyjson_is_str(v)) {
if (required)
http_appendf(s->line, sizeof(s->line), pos, ",\"%s\":\"\"", key);
return;
}
ui_log_copy_field(s->field, sizeof(s->field), yyjson_get_str(v), UI_LOG_FIELD_MAX);
cbm_json_escape(s->esc, (int)sizeof(s->esc), s->field);
http_appendf(s->line, sizeof(s->line), pos, ",\"%s\":\"%s\"", key, s->esc);
}
static void ui_log_add_int(ui_log_scratch_t *s, int *pos, const char *key, yyjson_val *v) {
if (!v || !yyjson_is_num(v))
return;
http_appendf(s->line, sizeof(s->line), pos, ",\"%s\":%lld", key, (long long)yyjson_get_sint(v));
}
static void ui_log_utc_now(char *out, size_t outsz) {
time_t now = time(NULL);
struct tm tm_utc;
if (cbm_gmtime_r(&now, &tm_utc) && strftime(out, outsz, "%Y-%m-%dT%H:%M:%SZ", &tm_utc) > 0)
return;
snprintf(out, outsz, "%lld", (long long)now);
}
static void handle_ui_log_post(cbm_http_conn_t *c, const cbm_http_req_t *req) {
if (req->body_len > UI_LOG_BODY_MAX) {
cbm_http_replyf(c, 413, g_cors_json, "{\"error\":\"body exceeds %d bytes\"}",
UI_LOG_BODY_MAX);
return;
}
if (req->body_len == 0) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
return;
}
yyjson_doc *doc = yyjson_read(req->body, req->body_len, 0);
if (!doc) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid json\"}");
return;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *entries = root ? yyjson_obj_get(root, "entries") : NULL;
if (!root || !yyjson_is_obj(root) || !entries || !yyjson_is_arr(entries)) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"entries[] required\"}");
return;
}
ui_log_scratch_t *s = calloc(1, sizeof(*s));
char path[CBM_SZ_1K];
if (!s || !cbm_ui_log_file_path(path, sizeof(path))) {
free(s);
yyjson_doc_free(doc);
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"no log path\"}");
return;
}
/* Page and session are the same for every entry of a post. */
yyjson_val *v_page = yyjson_obj_get(root, "page");
yyjson_val *v_session = yyjson_obj_get(root, "session");
ui_log_copy_field(s->field, sizeof(s->field),
(v_page && yyjson_is_str(v_page)) ? yyjson_get_str(v_page) : "", 256);
cbm_json_escape(s->page, (int)sizeof(s->page), s->field);
ui_log_copy_field(s->field, sizeof(s->field),
(v_session && yyjson_is_str(v_session)) ? yyjson_get_str(v_session) : "",
128);
cbm_json_escape(s->session, (int)sizeof(s->session), s->field);
char received[40];
ui_log_utc_now(received, sizeof(received));
int accepted = 0;
int dropped = 0;
bool file_error = false;
cbm_mutex_lock(&g_ui_log_mutex);
int64_t size = cbm_file_size(path);
if (size >= ui_log_rotate_bytes()) {
char previous[CBM_SZ_1K + 8];
snprintf(previous, sizeof(previous), "%s.1", path);
(void)cbm_rename_replace(path, previous);
}
FILE *f = cbm_fopen(path, "ab");
if (!f) {
char logdir[CBM_SZ_1K];
snprintf(logdir, sizeof(logdir), "%s", path);
char *slash = strrchr(logdir, '/');
if (slash) {
*slash = '\0';
cbm_mkdir_p(logdir, 0755);
}
f = cbm_fopen(path, "ab");
}
file_error = f == NULL;
size_t idx, max;
yyjson_val *entry;
yyjson_arr_foreach(entries, idx, max, entry) {
if (!yyjson_is_obj(entry) || accepted >= UI_LOG_ENTRIES_MAX) {
dropped++;
continue;
}
const char *level = ui_log_level_name(yyjson_obj_get(entry, "level"));
yyjson_val *v_source = yyjson_obj_get(entry, "source");
yyjson_val *v_message = yyjson_obj_get(entry, "message");
int pos = 0;
http_appendf(s->line, sizeof(s->line), &pos,
"{\"received\":\"%s\",\"page\":\"%s\",\"session\":\"%s\"", received, s->page,
s->session);
ui_log_add_int(s, &pos, "seq", yyjson_obj_get(entry, "seq"));
ui_log_add_str(s, &pos, "ts", yyjson_obj_get(entry, "ts"), false);
http_appendf(s->line, sizeof(s->line), &pos, ",\"level\":\"%s\"", level);
ui_log_add_str(s, &pos, "source", v_source, true);
ui_log_add_str(s, &pos, "message", v_message, true);
ui_log_add_str(s, &pos, "detail", yyjson_obj_get(entry, "detail"), false);
ui_log_add_str(s, &pos, "stack", yyjson_obj_get(entry, "stack"), false);
ui_log_add_str(s, &pos, "url", yyjson_obj_get(entry, "url"), false);
ui_log_add_int(s, &pos, "line", yyjson_obj_get(entry, "line"));
ui_log_add_int(s, &pos, "col", yyjson_obj_get(entry, "col"));
http_appendf(s->line, sizeof(s->line), &pos, "}\n");
if ((size_t)pos >= sizeof(s->line)) {
/* Cannot happen with the field caps above; never write a torn line. */
dropped++;
continue;
}
if (f && fputs(s->line, f) == EOF)
file_error = true;
accepted++;
if (strcmp(level, "warn") == 0 || strcmp(level, "error") == 0) {
char ring[LOG_LINE_MAX];
snprintf(ring, sizeof(ring), "ui.%s %.48s: %.400s", level,
(v_source && yyjson_is_str(v_source)) ? yyjson_get_str(v_source) : "",
(v_message && yyjson_is_str(v_message)) ? yyjson_get_str(v_message) : "");
cbm_ui_log_append(ring);
}
}
if (f) {
if (fflush(f) != 0)
file_error = true;
fclose(f);
}
cbm_mutex_unlock(&g_ui_log_mutex);
cbm_json_escape(s->esc, (int)sizeof(s->esc), path);
cbm_http_replyf(c, 200, g_cors_json,
"{\"accepted\":%d,\"dropped\":%d,\"path\":\"%s\",\"file_error\":%s}", accepted,
dropped, s->esc, file_error ? "true" : "false");
free(s);
yyjson_doc_free(doc);
}
/* Append `s` as the inside of a JSON string; the caller writes the quotes. */
static void ui_log_append_json_text(char *buf, size_t bufsz, int *pos, const char *s, size_t len) {
for (size_t i = 0; i < len; i++) {
if (*pos < 0 || (size_t)*pos + 8 >= bufsz) {
*pos = (int)bufsz;
return;
}
unsigned char ch = (unsigned char)s[i];
if (ch == '"' || ch == '\\') {
buf[(*pos)++] = '\\';
buf[(*pos)++] = (char)ch;
} else if (ch == '\n') {
buf[(*pos)++] = '\\';
buf[(*pos)++] = 'n';
} else if (ch == '\r') {
buf[(*pos)++] = '\\';
buf[(*pos)++] = 'r';
} else if (ch == '\t') {
buf[(*pos)++] = '\\';
buf[(*pos)++] = 't';
} else if (ch < 0x20) {
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\\u%04x", (unsigned)ch);
} else {
buf[(*pos)++] = (char)ch;
}
}
}
/* GET /api/ui-log?lines=N — the last N lines of the frontend log file.
*
* Reads at most the last 256 KiB of the file, so a tail is a bounded read
* whatever the rotation size. `partial` is true when the answer does not
* start at the first line of the file, either because more lines exist than
* were asked for or because the window cut the file. */
static void handle_ui_log_get(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char lines_str[16] = {0};
int max_lines = UI_LOG_TAIL_DEFAULT_LINES;
if (cbm_http_query_param(req->query, "lines", lines_str, (int)sizeof(lines_str))) {
int v = atoi(lines_str);
if (v > 0 && v <= UI_LOG_TAIL_MAX_LINES)
max_lines = v;
}
char path[CBM_SZ_1K];
if (!cbm_ui_log_file_path(path, sizeof(path))) {
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"no log path\"}");
return;
}
char previous[CBM_SZ_1K + 8];
snprintf(previous, sizeof(previous), "%s.1", path);
cbm_mutex_lock(&g_ui_log_mutex);
int64_t size = cbm_file_size(path);
bool has_previous = cbm_file_exists(previous);
char *data = NULL;
size_t got = 0;
bool partial = false;
if (size > 0) {
size_t want =
size > (int64_t)UI_LOG_TAIL_READ_BYTES ? (size_t)UI_LOG_TAIL_READ_BYTES : (size_t)size;
FILE *f = cbm_fopen(path, "rb");
if (f) {
data = malloc(want + 1);
if (data) {
if (size > (int64_t)want) {
partial = true;
if (fseek(f, (long)(size - (int64_t)want), SEEK_SET) != 0)
partial = false;
}
got = fread(data, 1, want, f);
data[got] = '\0';
}
fclose(f);
}
}
cbm_mutex_unlock(&g_ui_log_mutex);
/* A window that starts inside a line begins after that line's end. */
size_t start = 0;
if (partial && data) {
const char *nl = memchr(data, '\n', got);
start = nl ? (size_t)(nl - data) + 1 : got;
}
int total = 0;
for (size_t i = start; i < got; i++) {
if (data[i] == '\n')
total++;
}
if (got > start && data[got - 1] != '\n')
total++;
int skip = total > max_lines ? total - max_lines : 0;
if (skip > 0)
partial = true;
size_t buf_size = 6 * (got - start) + 2 * sizeof(path) + 512;
char *buf = malloc(buf_size);
if (!buf) {
free(data);
cbm_http_replyf(c, 500, g_cors, "oom");
return;
}
int pos = 0;
http_appendf(buf, buf_size, &pos, "{\"path\":\"");
ui_log_append_json_text(buf, buf_size, &pos, path, strlen(path));
http_appendf(buf, buf_size, &pos, "\"");
if (has_previous) {
http_appendf(buf, buf_size, &pos, ",\"previous_path\":\"");
ui_log_append_json_text(buf, buf_size, &pos, previous, strlen(previous));
http_appendf(buf, buf_size, &pos, "\"");
}
http_appendf(buf, buf_size, &pos, ",\"size_bytes\":%lld,\"partial\":%s,\"lines\":[",
(long long)(size > 0 ? size : 0), partial ? "true" : "false");
int emitted = 0;
int seen = 0;
size_t line_start = start;
for (size_t i = start; i <= got; i++) {
if (i < got && data[i] != '\n')
continue;
if (i == got && line_start == got)
break;
if (seen++ >= skip) {
if (emitted > 0)
http_appendf(buf, buf_size, &pos, ",");
http_appendf(buf, buf_size, &pos, "\"");
ui_log_append_json_text(buf, buf_size, &pos, data + line_start, i - line_start);
http_appendf(buf, buf_size, &pos, "\"");
emitted++;
}
line_start = i + 1;
}
http_appendf(buf, buf_size, &pos, "],\"total\":%d}", total);
if ((size_t)pos >= buf_size)
pos = (int)buf_size - 1;
buf[pos] = '\0';
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
free(buf);
free(data);
}
/* ── Process monitoring ───────────────────────────────────────── */
#ifndef _WIN32
@@ -2754,6 +3138,18 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
return;
}
/* POST /api/ui-log → the frontend's console and errors, to a file */
if (is_post && cbm_http_path_match(req->path, "/api/ui-log")) {
handle_ui_log_post(c, req);
return;
}
/* GET /api/ui-log → the tail of that file */
if (is_get && cbm_http_path_match(req->path, "/api/ui-log*")) {
handle_ui_log_get(c, req);
return;
}
/* GET / → index.html (no-cache so browser always gets latest) */
if (cbm_http_path_match(req->path, "/")) {
const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html");
+5
View File
@@ -106,6 +106,11 @@ void cbm_ui_log_init(void);
/* Append a log line to the UI ring buffer (called from log hook). */
void cbm_ui_log_append(const char *line);
/* Where the frontend's log file lives: <cache_dir>/logs/ui.log, one JSON
* line per entry. Written by POST /api/ui-log, read by GET /api/ui-log.
* Returns false when the path does not fit in out. */
bool cbm_ui_log_file_path(char *out, size_t outsz);
/* Set the binary path for subprocess spawning (call from main). */
void cbm_http_server_set_binary_path(const char *path);
+212
View File
@@ -1048,6 +1048,215 @@ TEST(ui_server_free_never_joins_active_index_worker) {
PASS();
}
/* ── Frontend log file (POST/GET /api/ui-log) ─────────────────── */
static int ui_log_read_file(const char *path, char *out, size_t outsz) {
FILE *f = cbm_fopen(path, "rb");
if (!f)
return -1;
size_t n = fread(out, 1, outsz - 1, f);
fclose(f);
out[n] = '\0';
return (int)n;
}
static int ui_log_count_char(const char *s, char ch) {
int n = 0;
for (; *s; s++) {
if (*s == ch)
n++;
}
return n;
}
static int ui_log_post(int port, const char *body, char *resp, size_t respsz) {
size_t cap = strlen(body) + 512;
char *req = malloc(cap);
if (!req)
return 0;
snprintf(req, cap,
"POST /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Content-Type: application/json\r\nContent-Length: %zu\r\n\r\n%s",
port, strlen(body), body);
int n = th_http(port, req, resp, respsz);
free(req);
return n;
}
/* One post, two entries: both land as JSON lines in <cache>/logs/ui.log with
* the fields the frontend sent, GET /api/ui-log tails the file back, and the
* error (not the plain log line) is mirrored into the ring GET /api/logs
* serves. */
TEST(ui_log_post_writes_jsonl_and_tail_reads_it) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
const char *body =
"{\"page\":\"/?project=demo\",\"session\":\"s1\",\"entries\":["
"{\"ts\":\"2026-09-08T10:00:00.000Z\",\"seq\":1,\"level\":\"error\",\"source\":\"rpc\","
"\"message\":\"get_code_snippet returned no source\",\"detail\":\"HTTP 200\","
"\"stack\":\"Error: x\\n at y\"},"
"{\"ts\":\"2026-09-08T10:00:01.000Z\",\"seq\":2,\"level\":\"log\",\"source\":\"console\","
"\"message\":\"galaxy \\\"ready\\\"\"}]}";
char resp[16384];
ASSERT_TRUE(ui_log_post(port, body, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"accepted\":2"));
ASSERT_NOT_NULL(strstr(resp, "\"dropped\":0"));
ASSERT_NOT_NULL(strstr(resp, "\"file_error\":false"));
ASSERT_NOT_NULL(strstr(resp, "/logs/ui.log\""));
char path[1024];
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
char content[8192];
ASSERT_TRUE(ui_log_read_file(path, content, sizeof(content)) > 0);
ASSERT_EQ(ui_log_count_char(content, '\n'), 2);
ASSERT_TRUE(strncmp(content, "{\"received\":\"", 13) == 0);
ASSERT_NOT_NULL(strstr(content, "\"page\":\"/?project=demo\",\"session\":\"s1\",\"seq\":1"));
ASSERT_NOT_NULL(strstr(content, "\"level\":\"error\",\"source\":\"rpc\","
"\"message\":\"get_code_snippet returned no source\","
"\"detail\":\"HTTP 200\",\"stack\":\"Error: x\\n at y\"}"));
ASSERT_NOT_NULL(strstr(content,
"\"seq\":2,\"ts\":\"2026-09-08T10:00:01.000Z\",\"level\":\"log\","
"\"source\":\"console\",\"message\":\"galaxy \\\"ready\\\"\"}"));
/* The tail, asked for one line: the newest, and it says it is partial. */
char req[256];
snprintf(req, sizeof(req), "GET /api/ui-log?lines=1 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"partial\":true"));
ASSERT_NOT_NULL(strstr(resp, "\"total\":2"));
ASSERT_NOT_NULL(strstr(resp, "galaxy"));
ASSERT_TRUE(strstr(resp, "get_code_snippet") == NULL);
ASSERT_NOT_NULL(strstr(resp, "\"size_bytes\":"));
ASSERT_TRUE(strstr(resp, "previous_path") == NULL);
/* Asked for more than there is: both, complete. */
snprintf(req, sizeof(req), "GET /api/ui-log?lines=10 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"partial\":false"));
ASSERT_NOT_NULL(strstr(resp, "get_code_snippet"));
ASSERT_NOT_NULL(strstr(resp, "galaxy"));
/* The ring carries the error, not the log line. */
snprintf(req, sizeof(req), "GET /api/logs?lines=50 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "ui.error rpc: get_code_snippet returned no source"));
ASSERT_TRUE(strstr(resp, "ui.log console") == NULL);
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
/* Refused bodies never create the file: empty, not JSON, no entries[], and a
* post above the 64 KiB cap. Entries that are not objects are counted as
* dropped rather than refused, so one bad entry does not cost the rest. */
TEST(ui_log_post_refuses_bad_bodies) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char resp[4096];
char req[512];
snprintf(req, sizeof(req),
"POST /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n"
"Content-Type: application/json\r\nContent-Length: 0\r\n\r\n",
port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 400);
ASSERT_TRUE(ui_log_post(port, "not json", resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 400);
ASSERT_TRUE(ui_log_post(port, "{\"page\":\"/\"}", resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 400);
size_t big_len = 70000;
char *big = malloc(big_len + 64);
ASSERT_NOT_NULL(big);
strcpy(big, "{\"entries\":[{\"message\":\"");
size_t at = strlen(big);
memset(big + at, 'a', big_len);
strcpy(big + at + big_len, "\"}]}");
ASSERT_TRUE(ui_log_post(port, big, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 413);
free(big);
char path[1024];
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
ASSERT_TRUE(!cbm_file_exists(path));
ASSERT_TRUE(ui_log_post(port, "{\"entries\":[42,{\"message\":\"kept\"}]}", resp, sizeof(resp)) >
0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"accepted\":1,\"dropped\":1"));
ASSERT_TRUE(cbm_file_exists(path));
th_server_stop(&ts);
ui_delete_fixture_cleanup(&fx);
PASS();
}
/* The file rotates once to ui.log.1 when it has reached the configured size
* at the time of a post; the tail names the previous file. */
TEST(ui_log_rotates_at_configured_size) {
ui_delete_fixture_t fx;
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
cbm_setenv("CBM_UI_LOG_ROTATE_BYTES", "300", 1);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
int port = cbm_http_server_port(ts.srv);
char resp[8192];
const char *names[] = {"first", "second", "third"};
for (int i = 0; i < 3; i++) {
char body[1024];
char pad[320];
memset(pad, 'p', sizeof(pad) - 1);
pad[sizeof(pad) - 1] = '\0';
snprintf(body, sizeof(body), "{\"entries\":[{\"level\":\"info\",\"message\":\"%s %s\"}]}",
names[i], pad);
ASSERT_TRUE(ui_log_post(port, body, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
}
char path[1024], previous[1040];
snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir);
snprintf(previous, sizeof(previous), "%s.1", path);
char content[4096];
ASSERT_TRUE(ui_log_read_file(path, content, sizeof(content)) > 0);
ASSERT_NOT_NULL(strstr(content, "\"message\":\"third "));
ASSERT_TRUE(strstr(content, "second") == NULL);
ASSERT_TRUE(ui_log_read_file(previous, content, sizeof(content)) > 0);
ASSERT_NOT_NULL(strstr(content, "\"message\":\"second "));
ASSERT_TRUE(strstr(content, "first") == NULL);
char req[256];
snprintf(req, sizeof(req), "GET /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port);
ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"previous_path\":\""));
ASSERT_NOT_NULL(strstr(resp, "ui.log.1\""));
ASSERT_NOT_NULL(strstr(resp, "third "));
th_server_stop(&ts);
cbm_unsetenv("CBM_UI_LOG_ROTATE_BYTES");
ui_delete_fixture_cleanup(&fx);
PASS();
}
/* The UI's CSP stays loopback-only: the served page may reach the server
* itself and the two loopback services the reader can start (the local-model
* sidecar on 4141, the agent bridge on 4142), and nothing else. Every host
@@ -2486,6 +2695,9 @@ SUITE(httpd) {
RUN_TEST(ui_server_routes_indexing_through_joinable_daemon_executor);
RUN_TEST(ui_server_free_never_joins_active_index_worker);
RUN_TEST(ui_csp_connect_src_is_loopback_only);
RUN_TEST(ui_log_post_writes_jsonl_and_tail_reads_it);
RUN_TEST(ui_log_post_refuses_bad_bodies);
RUN_TEST(ui_log_rotates_at_configured_size);
RUN_TEST(ui_server_root_without_embedded_assets_is_not_found);
RUN_TEST(ui_server_same_origin_request_is_allowed);
RUN_TEST(ui_server_rejects_foreign_and_null_origins);