mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
a338c1cae1
commit
9dc882a82f
23 files changed
+1996
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user