/** * 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; 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 { // 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( url: string, token: string, validate: (data: unknown) => T, signal?: AbortSignal, ): Promise { 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 { return fetchApi( url, token, (data) => { const obj = data as Record; 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 { return fetchApi( url, token, (data) => { const obj = data as Record; 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( url: string, token: string, validate: (data: unknown) => T, signal?: AbortSignal, ): Promise { 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 { const params: Record = {}; 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( 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( 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 = {}; 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( 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( 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( 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( 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( (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( (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"); }, }); }