ML-164: implementation

This commit is contained in:
Claudio Ortolina
2026-05-04 13:47:34 +01:00
parent 904f540031
commit 26a9469b3f
4 changed files with 883 additions and 7 deletions
+844 -3
View File
@@ -1,5 +1,5 @@
/**
* Production Error Tools
* Production Error Tools & Browser
*
* Fetches and displays production error data from the /api/v1/errors JSON API.
*
@@ -8,8 +8,11 @@
* PI_SERVICE_FQDN_WEB — Production domain (e.g., https://musiclibrary.example.com)
*
* Tools:
* fetch_production_errors — List/filter errors with pagination
* fetch_production_error — Single error detail with occurrences and stacktraces
* fetch_production_errors — List/filter errors with pagination
* fetch_production_error — Single error detail with occurrences and stacktraces
*
* Commands:
* /prod-errors — Interactive TUI for browsing production errors
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -19,6 +22,12 @@ import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
} from "@mariozechner/pi-coding-agent";
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
import {
matchesKey,
Key,
truncateToWidth,
} from "@mariozechner/pi-tui";
import { Type } from "typebox";
// ── Types ───────────────────────────────────────────────────────────────────
@@ -295,6 +304,734 @@ async function fetchError(
return data as ErrorDetailResponse;
}
// ── TUI Theme ───────────────────────────────────────────────────────────────
/** Minimal theme interface for color application in the ErrorBrowser. */
interface Theme {
fg(color: string, text: string): string;
bold(text: string): string;
}
// ── TUI Helpers ─────────────────────────────────────────────────────────────
function formatRelativeTime(iso8601: string): string {
const then = new Date(iso8601).getTime();
const now = Date.now();
const diffSec = Math.floor((now - then) / 1000);
if (diffSec < 60) return "just now";
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return `${diffHour}h ago`;
const diffDay = Math.floor(diffHour / 24);
return `${diffDay}d ago`;
}
function truncateReason(reason: string, maxLen: number): string {
// Take only the first line
const firstLine = reason.split("\n")[0] ?? reason;
if (firstLine.length <= maxLen) return firstLine;
return firstLine.slice(0, maxLen - 1) + "…";
}
// ── Error Browser Component ─────────────────────────────────────────────────
class ErrorBrowser {
// ── State ──────────────────────────────────────────────────────────────
private mode: "list" | "detail" | "loading" = "list";
private errors: ErrorListItem[];
private total: number;
private offset: number;
private readonly limit: number = 50;
private showResolved: boolean = true;
private showMuted: boolean = true;
private cursorIndex: number = 0;
private scrollOffset: number = 0;
private selectedError: ErrorDetail | null = null;
private detailScrollOffset: number = 0;
private readonly totalUnfiltered: number;
private readonly chromeHeight: number = 7;
private readonly errorEntryLines: number = 3;
// ── Callbacks ──────────────────────────────────────────────────────────
onClose?: () => void;
onCopy?: (text: string) => void;
// ── Caching ────────────────────────────────────────────────────────────
private cachedWidth?: number;
private cachedLines?: string[];
// ── HTTP ───────────────────────────────────────────────────────────────
private currentAbortController?: AbortController;
private readonly baseUrl: string;
private readonly token: string;
private readonly requestRender: () => void;
private readonly notify: (msg: string, level: "error" | "info" | "warning") => void;
constructor(
errors: ErrorListItem[],
total: number,
baseUrl: string,
token: string,
requestRender: () => void,
notify: (msg: string, level: "error" | "info" | "warning") => void,
) {
this.errors = errors;
this.total = total;
this.totalUnfiltered = total;
this.offset = 0;
this.baseUrl = baseUrl;
this.token = token;
this.requestRender = requestRender;
this.notify = notify;
this.clampCursor();
}
// ── Viewport ───────────────────────────────────────────────────────────
private get visibleHeight(): number {
return Math.max(10, process.stdout.rows - this.chromeHeight);
}
// ── Navigation helpers ─────────────────────────────────────────────────
private clampCursor(): void {
if (this.errors.length === 0) {
this.cursorIndex = -1;
} else {
this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, this.errors.length - 1));
}
}
private clampViewport(): void {
if (this.errors.length === 0) return;
const maxEntries = Math.floor(this.visibleHeight / this.errorEntryLines);
if (this.cursorIndex < this.scrollOffset) {
this.scrollOffset = this.cursorIndex;
} else if (this.cursorIndex >= this.scrollOffset + maxEntries) {
this.scrollOffset = this.cursorIndex - maxEntries + 1;
}
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.errors.length - 1));
}
// ── HTTP helpers ───────────────────────────────────────────────────────
private abortInFlight(): void {
if (this.currentAbortController) {
this.currentAbortController.abort();
this.currentAbortController = undefined;
}
}
private buildQueryParams(): Record<string, string> {
const params: Record<string, string> = {};
if (!this.showResolved) params.status = "unresolved";
if (!this.showMuted) params.muted = "false";
params.limit = String(this.limit);
params.offset = String(this.offset);
return params;
}
// ── Async operations ───────────────────────────────────────────────────
toggleResolved(): void {
if (this.mode === "loading") return;
this.abortInFlight();
this.showResolved = !this.showResolved;
this.offset = 0;
this.cursorIndex = 0;
this.scrollOffset = 0;
this.mode = "loading";
this.invalidate();
this.requestRender();
const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams());
const controller = new AbortController();
this.currentAbortController = controller;
fetchErrors(url, this.token, controller.signal)
.then((data) => {
this.errors = data.errors;
this.total = data.total;
this.offset = data.offset ?? 0;
this.cursorIndex = 0;
this.scrollOffset = 0;
this.clampCursor();
this.clampViewport();
this.mode = "list";
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
this.mode = "list";
const msg = err instanceof Error ? err.message : String(err);
this.notify(`Filter toggle failed: ${msg}`, "error");
this.invalidate();
this.requestRender();
});
}
toggleMuted(): void {
if (this.mode === "loading") return;
this.abortInFlight();
this.showMuted = !this.showMuted;
this.offset = 0;
this.cursorIndex = 0;
this.scrollOffset = 0;
this.mode = "loading";
this.invalidate();
this.requestRender();
const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams());
const controller = new AbortController();
this.currentAbortController = controller;
fetchErrors(url, this.token, controller.signal)
.then((data) => {
this.errors = data.errors;
this.total = data.total;
this.offset = data.offset ?? 0;
this.cursorIndex = 0;
this.scrollOffset = 0;
this.clampCursor();
this.clampViewport();
this.mode = "list";
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
this.mode = "list";
const msg = err instanceof Error ? err.message : String(err);
this.notify(`Filter toggle failed: ${msg}`, "error");
this.invalidate();
this.requestRender();
});
}
loadMore(): void {
if (this.mode === "loading") return;
if (this.offset + this.limit >= this.total) return; // end of results
this.abortInFlight();
this.offset = this.offset + this.limit;
this.mode = "loading";
this.invalidate();
this.requestRender();
const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams());
const controller = new AbortController();
this.currentAbortController = controller;
fetchErrors(url, this.token, controller.signal)
.then((data) => {
// Append new errors (deduplicate by id)
const existingIds = new Set(this.errors.map((e) => e.id));
const newErrors = data.errors.filter((e) => !existingIds.has(e.id));
this.errors = [...this.errors, ...newErrors];
this.total = data.total;
// offset already incremented before fetch; keep it
this.clampCursor();
this.clampViewport();
this.mode = "list";
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
// Revert offset on failure
this.offset = this.offset - this.limit;
this.mode = "list";
const msg = err instanceof Error ? err.message : String(err);
this.notify(`Load more failed: ${msg}`, "error");
this.invalidate();
this.requestRender();
});
}
fetchDetail(id: number): void {
if (this.mode === "loading") return;
this.abortInFlight();
this.mode = "loading";
this.detailScrollOffset = 0;
this.invalidate();
this.requestRender();
const url = buildUrl(this.baseUrl, `/api/v1/errors/${id}`);
const controller = new AbortController();
this.currentAbortController = controller;
fetchError(url, this.token, controller.signal)
.then((data) => {
this.selectedError = data.error;
this.detailScrollOffset = 0;
this.mode = "detail";
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
this.mode = "list";
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("404")) {
this.notify(`Error #${id} not found`, "warning");
} else {
this.notify(`Failed to load error detail: ${msg}`, "error");
}
this.invalidate();
this.requestRender();
});
}
// ── Input handling ─────────────────────────────────────────────────────
handleInput(data: string): void {
// Global keys (work in any mode)
if (matchesKey(data, Key.escape)) {
if (this.mode === "detail") {
// Back to list
this.mode = "list";
this.selectedError = null;
this.detailScrollOffset = 0;
this.invalidate();
return;
}
this.onClose?.();
return;
}
if (matchesKey(data, "q")) {
if (this.mode !== "detail") {
this.onClose?.();
return;
}
}
// ── List mode keys ────────────────────────────────────────────────
if (this.mode === "list" || this.mode === "loading") {
if (matchesKey(data, "r")) {
this.toggleResolved();
return;
}
if (matchesKey(data, "m")) {
this.toggleMuted();
return;
}
if (matchesKey(data, "l")) {
if (this.offset + this.limit >= this.total) return;
this.loadMore();
return;
}
if (matchesKey(data, Key.enter)) {
if (this.errors.length > 0 && this.cursorIndex >= 0) {
const error = this.errors[this.cursorIndex];
if (error) {
this.fetchDetail(error.id);
}
}
return;
}
if (this.mode !== "list") return; // block navigation while loading
let moved = false;
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
this.cursorIndex--;
moved = true;
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
this.cursorIndex++;
moved = true;
} else if (matchesKey(data, Key.pageUp)) {
const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines);
this.cursorIndex -= pageSize;
moved = true;
} else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines);
this.cursorIndex += pageSize;
moved = true;
} else if (matchesKey(data, Key.ctrl("b"))) {
const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines);
this.cursorIndex -= pageSize;
moved = true;
} else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
this.cursorIndex = 0;
moved = true;
} else if (matchesKey(data, Key.end) || matchesKey(data, "G")) {
this.cursorIndex = this.errors.length - 1;
moved = true;
}
if (moved) {
this.clampCursor();
this.clampViewport();
this.invalidate();
}
return;
}
// ── Detail mode keys ───────────────────────────────────────────────
if (this.mode === "detail") {
if (matchesKey(data, Key.enter)) {
// Copy current line to editor (prod-logs pattern)
if (this.selectedError) {
const detailLines = this.buildDetailLines();
const idx = this.detailScrollOffset;
if (idx < detailLines.length) {
this.onCopy?.(detailLines[idx]!);
}
}
return;
}
let moved = false;
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
this.detailScrollOffset--;
moved = true;
} else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
this.detailScrollOffset++;
moved = true;
} else if (matchesKey(data, Key.pageUp)) {
this.detailScrollOffset -= this.visibleHeight;
moved = true;
} else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
this.detailScrollOffset += this.visibleHeight;
moved = true;
} else if (matchesKey(data, Key.ctrl("b"))) {
this.detailScrollOffset -= this.visibleHeight;
moved = true;
} else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
this.detailScrollOffset = 0;
moved = true;
} else if (matchesKey(data, Key.end) || matchesKey(data, "G")) {
this.detailScrollOffset = Math.max(0, this.buildDetailLines().length - 1);
moved = true;
}
if (moved) {
this.detailScrollOffset = Math.max(
0,
Math.min(this.detailScrollOffset, Math.max(0, this.buildDetailLines().length - 1)),
);
this.invalidate();
}
return;
}
}
// ── Rendering ──────────────────────────────────────────────────────────
render(width: number, theme: Theme): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
let result: string[];
switch (this.mode) {
case "loading":
result = this.renderLoading(width, theme);
break;
case "detail":
result = this.renderDetail(width, theme);
break;
default:
result = this.renderList(width, theme);
break;
}
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
private renderLoading(width: number, theme: Theme): string[] {
const border = theme.fg("accent", "─".repeat(width));
const rows = process.stdout.rows;
const result: string[] = [border];
const mid = Math.floor((rows - 1) / 2);
for (let i = 1; i < rows - 1; i++) {
if (i === mid) {
result.push(truncateToWidth(theme.fg("muted", " Loading…"), width, ""));
} else {
result.push("");
}
}
result.push(border);
return result;
}
private renderList(width: number, theme: Theme): string[] {
const border = theme.fg("accent", "─".repeat(width));
const result: string[] = [];
// Top border
result.push(border);
// Header with counts
const unresolvedCount = this.errors.filter(
(e) => e.status === "unresolved",
).length;
const headerText = ` Production Errors (${this.total} total${unresolvedCount > 0 ? `, ${unresolvedCount} unresolved` : ""})`;
result.push(
truncateToWidth(theme.fg("accent", theme.bold(headerText)), width, ""),
);
// Divider
result.push(truncateToWidth(theme.fg("dim", "─".repeat(width)), width, ""));
// Empty states
if (this.errors.length === 0) {
const msg =
this.totalUnfiltered > 0
? "No errors match the current filters"
: "No production errors found";
result.push(truncateToWidth(theme.fg("muted", ` ${msg}`), width, ""));
// Pad remaining viewport
while (result.length < this.visibleHeight + 3) {
result.push("");
}
} else {
// Render errors within viewport
const maxEntries = Math.floor(this.visibleHeight / this.errorEntryLines);
const endIdx = Math.min(
this.scrollOffset + maxEntries,
this.errors.length,
);
let linesRendered = 0;
for (let i = this.scrollOffset; i < endIdx; i++) {
const error = this.errors[i];
if (!error) continue;
const isCursor = i === this.cursorIndex;
const cursor = isCursor ? "▶" : " ";
// Status badge color
let statusColor = "muted";
if (error.status === "unresolved") statusColor = "error";
else if (error.status === "resolved") statusColor = "success";
const mutedLabel = error.muted ? " [MUTED]" : "";
const statusBadge = theme.fg(statusColor, `[${error.status.toUpperCase()}]`);
// Line 1: cursor + status badge + kind + truncated reason
const reasonText = truncateReason(error.reason, Math.max(30, width - 25));
const line1 = `${cursor} ${statusBadge}${mutedLabel} ${error.kind}: ${reasonText}`;
result.push(truncateToWidth(isCursor ? theme.fg("accent", line1) : line1, width, ""));
// Line 2: source info
const sourceInfo = error.source_function
? ` ${error.source_function} ${error.source_line || ""}`
: ` ${error.source_line || "(no source)"}`;
result.push(truncateToWidth(theme.fg("dim", sourceInfo), width, ""));
// Line 3: last seen relative time
const lastSeen = error.last_occurrence_at
? ` Last seen ${formatRelativeTime(error.last_occurrence_at)}`
: " No occurrences";
result.push(truncateToWidth(theme.fg("dim", lastSeen), width, ""));
linesRendered += this.errorEntryLines;
}
// Pad remaining viewport if fewer lines than visible
const targetLines = 3 + this.visibleHeight;
while (result.length < targetLines) {
result.push("");
}
}
// Help lines
const resolvedLabel = this.showResolved ? "hide" : "show";
const mutedLabel = this.showMuted ? "hide" : "show";
const hasMore = this.offset + this.limit < this.total;
result.push(
truncateToWidth(
theme.fg(
"dim",
` ↑↓/jk navigate ↵ details r ${resolvedLabel} resolved m ${mutedLabel} muted`,
),
width,
"",
),
);
result.push(
truncateToWidth(
theme.fg(
"dim",
hasMore
? ` l load more q quit`
: ` — end of results — q quit`,
),
width,
"",
),
);
// Bottom border
result.push(border);
return result;
}
private buildDetailLines(): string[] {
const error = this.selectedError;
if (!error) return ["(no error selected)"];
const lines: string[] = [];
const sep = "─".repeat(40);
lines.push(`Error #${error.id}: ${error.kind}`);
lines.push(sep);
lines.push(`Reason: ${error.reason}`);
lines.push(`Status: ${error.status} | Muted: ${error.muted}`);
lines.push(
`Source: ${error.source_line || "—"}${error.source_function || "—"}`,
);
lines.push(`Fingerprint: ${error.fingerprint || "—"}`);
lines.push(`First occurrence: ${error.first_occurrence_at ?? "N/A"}`);
lines.push(`Last occurrence: ${error.last_occurrence_at}`);
lines.push(`Total occurrences: ${error.occurrence_count}`);
lines.push("");
const occurrences = error.occurrences ?? [];
lines.push(`Occurrences (${occurrences.length}):`);
lines.push(sep);
if (occurrences.length === 0) {
lines.push(" No occurrences recorded");
} else {
for (let i = 0; i < occurrences.length; i++) {
const occ = occurrences[i]!;
lines.push(`#${i + 1} ${occ.inserted_at}`);
lines.push(` Reason: ${occ.reason}`);
if (occ.context && Object.keys(occ.context).length > 0) {
lines.push(` Context:`);
for (const [k, v] of Object.entries(occ.context)) {
lines.push(` ${k}: ${JSON.stringify(v)}`);
}
}
if (occ.breadcrumbs && occ.breadcrumbs.length > 0) {
lines.push(` Breadcrumbs:`);
for (const crumb of occ.breadcrumbs) {
lines.push(`${crumb}`);
}
}
if (occ.stacktrace?.lines && occ.stacktrace.lines.length > 0) {
lines.push(` Stacktrace:`);
for (const sl of occ.stacktrace.lines) {
const app = sl.application || "—";
const loc = sl.file
? `${sl.file}${sl.line != null ? `:${sl.line}` : ""}`
: "(nofile)";
lines.push(
` ${app} / ${sl.module}.${sl.function}/${sl.arity} ${loc}`,
);
}
}
if (i < occurrences.length - 1) {
lines.push("");
}
}
}
return lines;
}
private renderDetail(width: number, theme: Theme): string[] {
const border = theme.fg("accent", "─".repeat(width));
const result: string[] = [];
// Top border
result.push(border);
// Header
const error = this.selectedError;
const headerText = error
? ` Error #${error.id}: ${error.kind}`
: " Error Detail";
result.push(
truncateToWidth(theme.fg("accent", theme.bold(headerText)), width, ""),
);
// Divider
result.push(truncateToWidth(theme.fg("dim", "─".repeat(width)), width, ""));
// Detail content (scrolled)
const detailLines = this.buildDetailLines();
const endIdx = Math.min(
this.detailScrollOffset + this.visibleHeight,
detailLines.length,
);
for (let i = this.detailScrollOffset; i < endIdx; i++) {
result.push(truncateToWidth(` ${detailLines[i]}`, width, ""));
}
// Pad remaining viewport
const targetLines = 3 + this.visibleHeight;
while (result.length < targetLines) {
result.push("");
}
// Help line
const fromLine = this.detailScrollOffset + 1;
const toLine = Math.min(
this.detailScrollOffset + this.visibleHeight,
detailLines.length,
);
const pct =
detailLines.length > 0
? Math.round((this.detailScrollOffset / detailLines.length) * 100)
: 0;
result.push(
truncateToWidth(
theme.fg(
"dim",
` Lines ${fromLine}-${toLine} of ${detailLines.length} (${pct}%) ↑↓/jk scroll Enter copy Escape back`,
),
width,
"",
),
);
// Bottom border
result.push(border);
return result;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
// ── Extension ───────────────────────────────────────────────────────────────
export default function prodErrorsExtension(pi: ExtensionAPI) {
@@ -552,4 +1289,108 @@ export default function prodErrorsExtension(pi: ExtensionAPI) {
};
},
});
// ── /prod-errors interactive command ──────────────────────────────────
pi.registerCommand("prod-errors", {
description:
"Browse production errors interactively via the /api/v1/errors JSON API",
handler: async (_args, ctx) => {
// ── Validate credentials ────────────────────────────────────────
const token = resolveVar("api_token");
const fqdn = resolveVar("service_fqdn_web");
const missing: string[] = [];
if (!token) missing.push("PI_API_TOKEN");
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
if (missing.length > 0) {
ctx.ui.notify(
`Missing environment variables: ${missing.join(", ")}`,
"error",
);
return;
}
// ── Fetch initial errors with loader UI ─────────────────────────
const errorsData = await ctx.ui.custom<ErrorListResponse | null>(
(tui, theme, _kb, done) => {
const loader = new BorderedLoader(
tui,
theme,
"Fetching production errors...",
);
loader.onAbort = () => done(null);
const url = buildUrl(fqdn!, "/api/v1/errors", {
limit: "50",
offset: "0",
});
fetchErrors(url, token!, loader.signal)
.then(done)
.catch((err) => {
console.error("[prod-errors] Initial fetch failed:", err);
done(null);
});
return loader;
},
);
if (errorsData === null) {
ctx.ui.notify("Cancelled or fetch failed — check logs for details", "info");
return;
}
if (errorsData.errors.length === 0) {
ctx.ui.notify("No production errors found", "info");
return;
}
// ── Interactive error browser ────────────────────────────────────
const baseUrl = fqdn!;
const apiToken = token!;
const copiedText = await ctx.ui.custom<string | null>(
(tui, theme, _kb, done) => {
const browser = new ErrorBrowser(
errorsData.errors,
errorsData.total,
baseUrl,
apiToken,
() => tui.requestRender(),
(msg, level) => ctx.ui.notify(msg, level),
);
browser.onClose = () => done(null);
browser.onCopy = (text: string) => done(text);
return {
render(width: number) {
return browser.render(width, theme);
},
invalidate() {
browser.invalidate();
},
handleInput(data: string) {
browser.handleInput(data);
tui.requestRender();
},
};
},
);
// ── Place copied text in editor ──────────────────────────────────
if (copiedText !== null) {
ctx.ui.setEditorText(copiedText);
}
ctx.ui.notify("Error browser closed", "info");
},
});
}