Files
music_library/.pi/extensions/prod-errors/index.ts
T
2026-05-05 16:33:43 +01:00

1911 lines
59 KiB
TypeScript

/**
* Production Error Tools & Browser
*
* Fetches and displays production error data from the /api/v1/errors JSON API.
*
* Credentials are read from environment variables:
* PI_API_TOKEN — Bearer token for API auth
* 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
*
* Commands:
* /prod-errors — Interactive TUI for browsing production errors
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import {
truncateTail,
formatSize,
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 ───────────────────────────────────────────────────────────────────
interface ErrorListItem {
id: number;
kind: string;
reason: string;
source_line: string;
source_function: string;
status: string;
fingerprint: string;
last_occurrence_at: string;
muted: boolean;
inserted_at: string;
updated_at: string;
}
interface ErrorListResponse {
errors: ErrorListItem[];
total: number;
limit: number;
offset: number;
}
interface StacktraceLine {
application: string;
module: string;
function: string;
arity: number;
file: string;
line: number;
}
interface Occurrence {
id: number;
reason: string;
context: Record<string, unknown>;
breadcrumbs: string[];
stacktrace: { lines: StacktraceLine[] };
error_id: number;
inserted_at: string;
}
interface ErrorDetail {
id: number;
kind: string;
reason: string;
source_line: string;
source_function: string;
status: string;
fingerprint: string;
last_occurrence_at: string;
muted: boolean;
inserted_at: string;
updated_at: string;
occurrence_count: number;
first_occurrence_at: string | null;
occurrences: Occurrence[];
}
interface ErrorDetailResponse {
error: ErrorDetail;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function resolveVar(name: string): string | undefined {
return process.env[`PI_${name.toUpperCase()}`];
}
function buildUrl(
base: string,
path: string,
params?: Record<string, string>,
): string {
// Strip trailing slash and ensure protocol.
// Default to https:// unless the host looks like a local/dev address.
let url = base.replace(/\/+$/, "");
if (!/^https?:\/\//i.test(url)) {
const isLocal =
url.startsWith("localhost") ||
url.startsWith("127.") ||
url.startsWith("0.0.0.0") ||
url.startsWith("[::1]");
url = `${isLocal ? "http" : "https"}://${url}`;
}
url = `${url}${path}`;
if (params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
searchParams.set(key, value);
}
}
const qs = searchParams.toString();
if (qs) {
url = `${url}?${qs}`;
}
}
return url;
}
// ── Formatting ──────────────────────────────────────────────────────────────
function formatErrorListItem(error: ErrorListItem, index: number): string {
const mutedLabel = error.muted ? " [MUTED]" : "";
return [
`#${index} [${error.status}]${mutedLabel} ${error.kind}: ${error.reason}`,
` Source: ${error.source_line}${error.source_function}`,
` Last occurrence: ${error.last_occurrence_at}`,
` Fingerprint: ${error.fingerprint}`,
` ID: ${error.id}`,
].join("\n");
}
function formatDetailLines(error: ErrorDetail): string[] {
const lines: string[] = [];
const sep = "─".repeat(44);
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(`Created: ${error.inserted_at} | Updated: ${error.updated_at}`);
const occurrences = error.occurrences ?? [];
lines.push("");
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}`);
// Context as key-value pairs
if (occ.context && Object.keys(occ.context).length > 0) {
const ctxLines = Object.entries(occ.context).map(
([k, v]) => ` ${k}: ${JSON.stringify(v)}`,
);
lines.push(` Context:`);
lines.push(...ctxLines);
}
// Breadcrumbs as bullet list
if (occ.breadcrumbs && occ.breadcrumbs.length > 0) {
lines.push(` Breadcrumbs:`);
for (const crumb of occ.breadcrumbs) {
lines.push(`${crumb}`);
}
}
// Stacktrace
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;
}
function formatErrorDetail(error: ErrorDetail): string {
return formatDetailLines(error).join("\n");
}
function applyOutputTruncation(output: string): string {
const truncation = truncateTail(output, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
if (truncation.truncated) {
return (
truncation.content +
`\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines ` +
`(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). ` +
`Use narrower filters to reduce output.]`
);
}
return truncation.content;
}
// ── HTTP helpers ────────────────────────────────────────────────────────────
async function fetchApi<T>(
url: string,
token: string,
validate: (data: unknown) => T,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "(unable to read body)");
const truncated = body.length > 500 ? body.slice(0, 500) + "..." : body;
throw new Error(
`API returned ${response.status} ${response.statusText}\n${truncated}`,
);
}
let data: unknown;
try {
data = await response.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to parse API response: ${message}. Status: ${response.status}.`,
);
}
return validate(data);
}
async function fetchErrors(
url: string,
token: string,
signal?: AbortSignal,
): Promise<ErrorListResponse> {
return fetchApi(
url,
token,
(data) => {
const obj = data as Record<string, unknown>;
if (!obj.errors || !Array.isArray(obj.errors)) {
throw new Error(
`Unexpected API response: 'errors' field is missing or not an array. Got: ${
obj.errors === null ? "null" : typeof obj.errors
}.`,
);
}
return data as ErrorListResponse;
},
signal,
);
}
async function fetchError(
url: string,
token: string,
signal?: AbortSignal,
): Promise<ErrorDetailResponse> {
return fetchApi(
url,
token,
(data) => {
const obj = data as Record<string, unknown>;
if (!obj.error || typeof obj.error !== "object") {
throw new Error(
`Unexpected API response: 'error' field is missing or not an object. Got: ${
obj.error === null ? "null" : typeof obj.error
}.`,
);
}
return data as ErrorDetailResponse;
},
signal,
);
}
async function postApi<T>(
url: string,
token: string,
validate: (data: unknown) => T,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "(unable to read body)");
const truncated = body.length > 500 ? body.slice(0, 500) + "..." : body;
throw new Error(
`API returned ${response.status} ${response.statusText}\n${truncated}`,
);
}
let data: unknown;
try {
data = await response.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to parse API response: ${message}. Status: ${response.status}.`,
);
}
return validate(data);
}
// ── 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();
if (isNaN(then)) return "unknown";
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 {
this.retoggleFilter("resolved");
}
toggleMuted(): void {
this.retoggleFilter("muted");
}
private retoggleFilter(filter: "resolved" | "muted"): void {
if (this.mode === "loading") return;
this.abortInFlight();
if (filter === "resolved") {
this.showResolved = !this.showResolved;
} else {
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 (${filter}) 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();
});
}
toggleMute(id: number, currentMuted: boolean): void {
if (this.mode === "loading") return;
this.abortInFlight();
this.mode = "loading";
this.invalidate();
this.requestRender();
const action = currentMuted ? "unmute" : "mute";
const url = buildUrl(this.baseUrl, `/api/v1/errors/${id}/${action}`);
const controller = new AbortController();
this.currentAbortController = controller;
postApi<ErrorDetailResponse>(
url,
this.token,
(data) => data as ErrorDetailResponse,
controller.signal,
)
.then((data) => {
// Update local error state in the list
const idx = this.errors.findIndex((e) => e.id === id);
if (idx !== -1) {
const updated = data.error;
this.errors[idx] = {
...this.errors[idx]!,
muted: updated.muted,
};
}
// Update detail view if viewing this error
if (this.selectedError && this.selectedError.id === id) {
this.selectedError = {
...this.selectedError,
muted: data.error.muted,
};
}
this.mode = this.selectedError ? "detail" : "list";
this.notify(`Error #${id} ${action}d successfully`, "info");
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
this.mode = this.selectedError ? "detail" : "list";
const msg = err instanceof Error ? err.message : String(err);
this.notify(`Toggle mute failed: ${msg}`, "error");
this.invalidate();
this.requestRender();
});
}
toggleResolve(id: number, currentStatus: string): void {
if (this.mode === "loading") return;
this.abortInFlight();
this.mode = "loading";
this.invalidate();
this.requestRender();
const action = currentStatus === "resolved" ? "unresolve" : "resolve";
const url = buildUrl(this.baseUrl, `/api/v1/errors/${id}/${action}`);
const controller = new AbortController();
this.currentAbortController = controller;
postApi<ErrorDetailResponse>(
url,
this.token,
(data) => data as ErrorDetailResponse,
controller.signal,
)
.then((data) => {
// Update local error state in the list
const idx = this.errors.findIndex((e) => e.id === id);
if (idx !== -1) {
const updated = data.error;
this.errors[idx] = {
...this.errors[idx]!,
status: updated.status,
};
}
// Update detail view if viewing this error
if (this.selectedError && this.selectedError.id === id) {
this.selectedError = {
...this.selectedError,
status: data.error.status,
};
}
this.mode = this.selectedError ? "detail" : "list";
this.notify(`Error #${id} ${action}d successfully`, "info");
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
this.mode = this.selectedError ? "detail" : "list";
const msg = err instanceof Error ? err.message : String(err);
this.notify(`Toggle resolve failed: ${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")) {
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, Key.shift("r"))) {
if (this.errors.length > 0 && this.cursorIndex >= 0) {
const error = this.errors[this.cursorIndex];
if (error) {
this.toggleResolve(error.id, error.status);
}
}
return;
}
if (matchesKey(data, Key.shift("m"))) {
if (this.errors.length > 0 && this.cursorIndex >= 0) {
const error = this.errors[this.cursorIndex];
if (error) {
this.toggleMute(error.id, error.muted);
}
}
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, Key.shift("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.shift("r"))) {
if (this.selectedError) {
this.toggleResolve(this.selectedError.id, this.selectedError.status);
}
return;
}
if (matchesKey(data, Key.shift("m"))) {
if (this.selectedError) {
this.toggleMute(this.selectedError.id, this.selectedError.muted);
}
return;
}
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, Key.shift("g"))
) {
this.detailScrollOffset = Math.max(
0,
this.buildDetailLines().length - 1,
);
moved = true;
}
if (moved) {
const maxOffset = Math.max(0, this.buildDetailLines().length - 1);
this.detailScrollOffset = Math.max(
0,
Math.min(this.detailScrollOffset, maxOffset),
);
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 viewportRows = this.visibleHeight + 3; // +3 for chrome lines
const mid = Math.floor(viewportRows / 2);
const result: string[] = [border];
for (let i = 1; i < viewportRows - 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 (3 chrome lines: border + header + divider)
const chromeLines = 3;
while (result.length < this.visibleHeight + chromeLines) {
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 M toggle mute R toggle resolve`,
),
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)"];
return formatDetailLines(error);
}
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 M mute R resolve 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) {
// ── fetch_production_errors tool ───────────────────────────────────────
pi.registerTool({
name: "fetch_production_errors",
label: "Fetch Production Errors",
description:
"Fetch production errors from the deployed application via the /api/v1/errors JSON API. " +
"Use this tool when investigating what errors are occurring in production, " +
"checking error frequency, browsing unresolved errors, or when the user asks " +
"about production errors. Supports filtering by status, muted state, and " +
"substring search on error reason. Output is truncated at 50KB / 2000 lines " +
"— use narrower filters to reduce output if truncation occurs.",
promptSnippet:
"Fetch recent production errors from the errors API (params: status, muted, search, limit, offset)",
promptGuidelines: [
"Use fetch_production_errors when investigating what errors are occurring in production, checking error frequency, or browsing unresolved errors.",
"Start with a small limit (e.g., 20) and filter by status or search before fetching large result sets. Use the 'search' parameter to find errors matching a specific reason or module.",
"Use fetch_production_error when you need full details on a specific error, including stacktraces and context. Get the error ID from fetch_production_errors first.",
],
parameters: Type.Object({
status: Type.Optional(
Type.Union([Type.Literal("resolved"), Type.Literal("unresolved")], {
description: "Filter by error status: 'resolved' or 'unresolved'.",
}),
),
muted: Type.Optional(
Type.Boolean({
description:
"Filter by muted state. true = only muted, false = only unmuted.",
}),
),
search: Type.Optional(
Type.String({
description:
"Substring match on error reason. Case-insensitive search to find specific errors.",
}),
),
limit: Type.Optional(
Type.Number({
description:
"Maximum number of errors to return. Default: 50. Use a smaller value to reduce output size.",
}),
),
offset: Type.Optional(
Type.Number({
description:
"Pagination offset. Number of errors to skip. Default: 0.",
}),
),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
// Early abort check
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
// 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) {
return {
content: [
{
type: "text",
text:
`Cannot fetch production errors: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
// Build query params for non-nil values
const queryParams: Record<string, string> = {};
if (params.status !== undefined && params.status !== null) {
queryParams.status = params.status;
}
if (params.muted !== undefined && params.muted !== null) {
queryParams.muted = String(params.muted);
}
if (
params.search !== undefined &&
params.search !== null &&
params.search !== ""
) {
queryParams.search = params.search;
}
if (params.limit !== undefined && params.limit !== null) {
queryParams.limit = String(params.limit);
}
if (params.offset !== undefined && params.offset !== null) {
queryParams.offset = String(params.offset);
}
const url = buildUrl(fqdn!, "/api/v1/errors", queryParams);
// Fetch
let data: ErrorListResponse;
try {
data = await fetchErrors(url, token!, signal);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [
{
type: "text",
text: `Failed to fetch production errors: ${message}`,
},
],
isError: true,
};
}
// Handle empty results
if (data.errors.length === 0) {
return {
content: [
{
type: "text",
text: "No errors found matching the given filters.",
},
],
details: {
total: data.total,
count: 0,
offset: data.offset,
limit: data.limit,
},
};
}
// Format output
const count = data.errors.length;
const from = (data.offset ?? 0) + 1;
const to = (data.offset ?? 0) + count;
const header = `Production Errors (total: ${data.total}, showing ${from}-${to})\n`;
const body = data.errors
.map((e, i) => formatErrorListItem(e, from + i))
.join("\n\n");
const output = applyOutputTruncation(header + body);
return {
content: [{ type: "text", text: output }],
details: {
total: data.total,
count,
offset: data.offset,
limit: data.limit,
},
};
},
});
// ── fetch_production_error tool ────────────────────────────────────────
pi.registerTool({
name: "fetch_production_error",
label: "Fetch Production Error Detail",
description:
"Fetch full details for a specific production error by ID, including all " +
"occurrences, stacktraces, context, and breadcrumbs. Use this tool when you " +
"need to investigate a specific error in depth. Get the error ID from " +
"fetch_production_errors first. Output is truncated at 50KB / 2000 lines.",
promptSnippet:
"Fetch full details for a specific production error by ID (param: id)",
promptGuidelines: [
"Use fetch_production_error when you need full details on a specific error, including stacktraces, context, and breadcrumbs from every occurrence.",
"Get the error ID from fetch_production_errors first. Pass it as the 'id' parameter. The output includes all occurrences and may be large — review it carefully before asking for more.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to fetch full details for. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
// Early abort check
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
// 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) {
return {
content: [
{
type: "text",
text:
`Cannot fetch production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
const url = buildUrl(fqdn!, `/api/v1/errors/${params.id}`);
// Fetch
let data: ErrorDetailResponse;
try {
data = await fetchError(url, token!, signal);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Check if it looks like a 404
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to fetch production error: ${message}`,
},
],
isError: true,
};
}
// Format output
const output = applyOutputTruncation(formatErrorDetail(data.error));
return {
content: [{ type: "text", text: output }],
details: {
errorId: params.id,
occurrenceCount: data.error.occurrence_count,
},
};
},
});
// ── mute_production_error tool ─────────────────────────────────────────
pi.registerTool({
name: "mute_production_error",
label: "Mute Production Error",
description:
"Mute a production error by ID. Muted errors will not trigger email " +
"notifications. Use this tool to silence noisy or already-addressed errors.",
promptSnippet:
"Mute a production error by ID to suppress email notifications",
promptGuidelines: [
"Use mute_production_error to silence notifications for a noisy or already-addressed production error.",
"Get the error ID from fetch_production_errors first. Muted errors still appear in listings but show a [MUTED] label.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to mute. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
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) {
return {
content: [
{
type: "text",
text:
`Cannot mute production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/mute`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text:
`Error #${params.id} muted successfully.` +
(data.error?.muted
? ""
: " (Warning: muted field not reflected in response)"),
},
],
details: { errorId: params.id, muted: data.error?.muted },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to mute production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── unmute_production_error tool ───────────────────────────────────────
pi.registerTool({
name: "unmute_production_error",
label: "Unmute Production Error",
description:
"Unmute a previously muted production error by ID, re-enabling email " +
"notifications for new occurrences.",
promptSnippet:
"Unmute a production error by ID to re-enable email notifications",
promptGuidelines: [
"Use unmute_production_error to re-enable notifications for a previously muted error.",
"Get the error ID from fetch_production_errors first.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to unmute. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
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) {
return {
content: [
{
type: "text",
text:
`Cannot unmute production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/unmute`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text: `Error #${params.id} unmuted successfully. Email notifications will resume for new occurrences.`,
},
],
details: { errorId: params.id, muted: data.error?.muted },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to unmute production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── resolve_production_error tool ───────────────────────────────────────
pi.registerTool({
name: "resolve_production_error",
label: "Resolve Production Error",
description:
"Mark a production error as resolved by ID. Resolved errors are filtered " +
"out of the default unresolved view.",
promptSnippet:
"Mark a production error as resolved when the underlying issue has been fixed",
promptGuidelines: [
"Use resolve_production_error to mark a production error as resolved when the underlying issue has been fixed.",
"Get the error ID from fetch_production_errors first.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to resolve. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
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) {
return {
content: [
{
type: "text",
text:
`Cannot resolve production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/resolve`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text:
`Error #${params.id} resolved successfully.` +
(data.error?.status === "resolved"
? ""
: " (Warning: status field not reflected in response)"),
},
],
details: { errorId: params.id, status: data.error?.status },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to resolve production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── unresolve_production_error tool ─────────────────────────────────────
pi.registerTool({
name: "unresolve_production_error",
label: "Unresolve Production Error",
description:
"Reopen a previously resolved production error by ID. Use when an error " +
"reoccurs after being marked as resolved.",
promptSnippet:
"Reopen a production error when it reoccurs after being resolved",
promptGuidelines: [
"Use unresolve_production_error to reopen a production error when it reoccurs after being resolved.",
"Get the error ID from fetch_production_errors first.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to unresolve. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
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) {
return {
content: [
{
type: "text",
text:
`Cannot unresolve production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/unresolve`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text: `Error #${params.id} reopened (unresolved) successfully.`,
},
],
details: { errorId: params.id, status: data.error?.status },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to unresolve production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── /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");
},
});
}