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. * 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) * PI_SERVICE_FQDN_WEB — Production domain (e.g., https://musiclibrary.example.com)
* *
* Tools: * Tools:
* fetch_production_errors — List/filter errors with pagination * fetch_production_errors — List/filter errors with pagination
* fetch_production_error — Single error detail with occurrences and stacktraces * 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"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -19,6 +22,12 @@ import {
DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES, DEFAULT_MAX_LINES,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
import {
matchesKey,
Key,
truncateToWidth,
} from "@mariozechner/pi-tui";
import { Type } from "typebox"; import { Type } from "typebox";
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
@@ -295,6 +304,734 @@ async function fetchError(
return data as ErrorDetailResponse; 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 ─────────────────────────────────────────────────────────────── // ── Extension ───────────────────────────────────────────────────────────────
export default function prodErrorsExtension(pi: ExtensionAPI) { 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");
},
});
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "prod-errors", "name": "prod-errors",
"private": true, "private": true,
"description": "Fetch production errors via the JSON API for LLM analysis" "description": "Fetch production errors via JSON API for LLM analysis, with interactive /prod-errors TUI"
} }
@@ -1,13 +1,18 @@
--- ---
id: ML-164 id: ML-164
title: Create pi extension for interactive error browsing title: Create pi extension for interactive error browsing
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-05-04 08:08' created_date: '2026-05-04 08:08'
updated_date: '2026-05-04 10:03' updated_date: '2026-05-04 12:44'
labels: labels:
- pi - pi
- ready
dependencies: [] dependencies: []
modified_files:
- .pi/extensions/prod-errors/index.ts
- .pi/extensions/prod-errors/package.json
- docs/production-infrastructure.md
parent_task_id: ML-161 parent_task_id: ML-161
priority: medium priority: medium
ordinal: 6000 ordinal: 6000
@@ -282,3 +287,28 @@ No new changes beyond ML-162/ML-163: `PI_API_TOKEN` and `PI_SERVICE_FQDN_WEB` ar
- `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui`: Already available. - `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui`: Already available.
- No new npm dependencies. - No new npm dependencies.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Implementation Notes
### What was built
Added the `/prod-errors` interactive TUI command to the existing `.pi/extensions/prod-errors/` extension (which already contained the `fetch_production_errors` and `fetch_production_error` LLM tools from ML-163).
### Files changed
- `.pi/extensions/prod-errors/index.ts` — Added ErrorBrowser class (~540 lines), Theme interface, formatRelativeTime/truncateReason helpers, and `/prod-errors` command registration. Total file grew from ~360 to ~1396 lines.
- `.pi/extensions/prod-errors/package.json` — Updated description to mention TUI command.
- `docs/production-infrastructure.md` — Added `/prod-errors` to the pi coding agent tools table and added command description.
### Key design decisions
1. **Reuses existing HTTP functions**`fetchErrors()` and `fetchError()` from ML-163 are reused directly by the ErrorBrowser class. The class builds URLs via the existing `buildUrl()` helper.
2. **Flat rendering (not Container/Text)** — Follows the prod-logs pattern of building arrays of styled strings, not the component-based approach using Container/Text/Box. This is simpler and directly compatible with the `ctx.ui.custom()` API.
3. **Self-contained class** — ErrorBrowser manages all state, navigation, rendering, and async operations internally. It receives `requestRender` and `notify` callbacks from the command handler for I/O.
4. **Occurrence count omission** — The list endpoint doesn't include `occurrence_count` (per ML-162 spec), so the list view shows relative timestamps instead.
5. **No collapsible stacktraces** — Deferred per implementation plan. Initial version shows all stacktraces inline with scrolling.
6. **Explicit load-more** — No auto-load on scroll-to-bottom. Uses `l` key for user-controlled pagination.
<!-- SECTION:NOTES:END -->
+6 -1
View File
@@ -238,13 +238,18 @@ runtime environment (not server-side config).
| Extension | Tools | Env vars | | Extension | Tools | Env vars |
|-----------|-------|----------| |-----------|-------|----------|
| `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` | | `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` |
| `prod-errors` | `fetch_production_errors`, `fetch_production_error` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` | | `prod-errors` | `fetch_production_errors`, `fetch_production_error`, `/prod-errors` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` |
**`prod-logs` env vars:** **`prod-logs` env vars:**
- `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`) - `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`)
- `PI_COOLIFY_APP_UUID` — Application UUID in Coolify - `PI_COOLIFY_APP_UUID` — Application UUID in Coolify
- `PI_COOLIFY_TOKEN` — Coolify API Bearer token - `PI_COOLIFY_TOKEN` — Coolify API Bearer token
**`prod-errors` tools and command:**
- `fetch_production_errors` — List/filter errors via LLM tool
- `fetch_production_error` — Single error detail via LLM tool
- `/prod-errors` — Interactive TUI for browsing errors (list, detail, filter toggles)
**`prod-errors` env vars:** **`prod-errors` env vars:**
- `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`) - `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`)
- `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash) - `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash)