diff --git a/.pi/extensions/prod-errors/index.ts b/.pi/extensions/prod-errors/index.ts index 69d9c0bb..3f20a70d 100644 --- a/.pi/extensions/prod-errors/index.ts +++ b/.pi/extensions/prod-errors/index.ts @@ -1,5 +1,5 @@ /** - * Production Error Tools + * Production Error Tools & Browser * * Fetches and displays production error data from the /api/v1/errors JSON API. * @@ -8,8 +8,11 @@ * PI_SERVICE_FQDN_WEB — Production domain (e.g., https://musiclibrary.example.com) * * Tools: - * fetch_production_errors — List/filter errors with pagination - * fetch_production_error — Single error detail with occurrences and stacktraces + * fetch_production_errors — List/filter errors with pagination + * fetch_production_error — Single error detail with occurrences and stacktraces + * + * Commands: + * /prod-errors — Interactive TUI for browsing production errors */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -19,6 +22,12 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; +import { + matchesKey, + Key, + truncateToWidth, +} from "@mariozechner/pi-tui"; import { Type } from "typebox"; // ── Types ─────────────────────────────────────────────────────────────────── @@ -295,6 +304,734 @@ async function fetchError( return data as ErrorDetailResponse; } +// ── TUI Theme ─────────────────────────────────────────────────────────────── + +/** Minimal theme interface for color application in the ErrorBrowser. */ +interface Theme { + fg(color: string, text: string): string; + bold(text: string): string; +} + +// ── TUI Helpers ───────────────────────────────────────────────────────────── + +function formatRelativeTime(iso8601: string): string { + const then = new Date(iso8601).getTime(); + const now = Date.now(); + const diffSec = Math.floor((now - then) / 1000); + + if (diffSec < 60) return "just now"; + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}h ago`; + + const diffDay = Math.floor(diffHour / 24); + return `${diffDay}d ago`; +} + +function truncateReason(reason: string, maxLen: number): string { + // Take only the first line + const firstLine = reason.split("\n")[0] ?? reason; + if (firstLine.length <= maxLen) return firstLine; + return firstLine.slice(0, maxLen - 1) + "…"; +} + +// ── Error Browser Component ───────────────────────────────────────────────── + +class ErrorBrowser { + // ── State ────────────────────────────────────────────────────────────── + + private mode: "list" | "detail" | "loading" = "list"; + private errors: ErrorListItem[]; + private total: number; + private offset: number; + private readonly limit: number = 50; + private showResolved: boolean = true; + private showMuted: boolean = true; + private cursorIndex: number = 0; + private scrollOffset: number = 0; + private selectedError: ErrorDetail | null = null; + private detailScrollOffset: number = 0; + private readonly totalUnfiltered: number; + private readonly chromeHeight: number = 7; + private readonly errorEntryLines: number = 3; + + // ── Callbacks ────────────────────────────────────────────────────────── + + onClose?: () => void; + onCopy?: (text: string) => void; + + // ── Caching ──────────────────────────────────────────────────────────── + + private cachedWidth?: number; + private cachedLines?: string[]; + + // ── HTTP ─────────────────────────────────────────────────────────────── + + private currentAbortController?: AbortController; + private readonly baseUrl: string; + private readonly token: string; + private readonly requestRender: () => void; + private readonly notify: (msg: string, level: "error" | "info" | "warning") => void; + + constructor( + errors: ErrorListItem[], + total: number, + baseUrl: string, + token: string, + requestRender: () => void, + notify: (msg: string, level: "error" | "info" | "warning") => void, + ) { + this.errors = errors; + this.total = total; + this.totalUnfiltered = total; + this.offset = 0; + this.baseUrl = baseUrl; + this.token = token; + this.requestRender = requestRender; + this.notify = notify; + this.clampCursor(); + } + + // ── Viewport ─────────────────────────────────────────────────────────── + + private get visibleHeight(): number { + return Math.max(10, process.stdout.rows - this.chromeHeight); + } + + // ── Navigation helpers ───────────────────────────────────────────────── + + private clampCursor(): void { + if (this.errors.length === 0) { + this.cursorIndex = -1; + } else { + this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, this.errors.length - 1)); + } + } + + private clampViewport(): void { + if (this.errors.length === 0) return; + const maxEntries = Math.floor(this.visibleHeight / this.errorEntryLines); + if (this.cursorIndex < this.scrollOffset) { + this.scrollOffset = this.cursorIndex; + } else if (this.cursorIndex >= this.scrollOffset + maxEntries) { + this.scrollOffset = this.cursorIndex - maxEntries + 1; + } + this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.errors.length - 1)); + } + + // ── HTTP helpers ─────────────────────────────────────────────────────── + + private abortInFlight(): void { + if (this.currentAbortController) { + this.currentAbortController.abort(); + this.currentAbortController = undefined; + } + } + + private buildQueryParams(): Record { + 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 { + if (this.mode === "loading") return; + this.abortInFlight(); + this.showResolved = !this.showResolved; + this.offset = 0; + this.cursorIndex = 0; + this.scrollOffset = 0; + this.mode = "loading"; + this.invalidate(); + this.requestRender(); + + const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams()); + const controller = new AbortController(); + this.currentAbortController = controller; + + fetchErrors(url, this.token, controller.signal) + .then((data) => { + this.errors = data.errors; + this.total = data.total; + this.offset = data.offset ?? 0; + this.cursorIndex = 0; + this.scrollOffset = 0; + this.clampCursor(); + this.clampViewport(); + this.mode = "list"; + this.invalidate(); + this.requestRender(); + }) + .catch((err) => { + if (err instanceof Error && err.name === "AbortError") return; + this.mode = "list"; + const msg = err instanceof Error ? err.message : String(err); + this.notify(`Filter toggle failed: ${msg}`, "error"); + this.invalidate(); + this.requestRender(); + }); + } + + toggleMuted(): void { + if (this.mode === "loading") return; + this.abortInFlight(); + this.showMuted = !this.showMuted; + this.offset = 0; + this.cursorIndex = 0; + this.scrollOffset = 0; + this.mode = "loading"; + this.invalidate(); + this.requestRender(); + + const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams()); + const controller = new AbortController(); + this.currentAbortController = controller; + + fetchErrors(url, this.token, controller.signal) + .then((data) => { + this.errors = data.errors; + this.total = data.total; + this.offset = data.offset ?? 0; + this.cursorIndex = 0; + this.scrollOffset = 0; + this.clampCursor(); + this.clampViewport(); + this.mode = "list"; + this.invalidate(); + this.requestRender(); + }) + .catch((err) => { + if (err instanceof Error && err.name === "AbortError") return; + this.mode = "list"; + const msg = err instanceof Error ? err.message : String(err); + this.notify(`Filter toggle failed: ${msg}`, "error"); + this.invalidate(); + this.requestRender(); + }); + } + + loadMore(): void { + if (this.mode === "loading") return; + if (this.offset + this.limit >= this.total) return; // end of results + + this.abortInFlight(); + this.offset = this.offset + this.limit; + this.mode = "loading"; + this.invalidate(); + this.requestRender(); + + const url = buildUrl(this.baseUrl, "/api/v1/errors", this.buildQueryParams()); + const controller = new AbortController(); + this.currentAbortController = controller; + + fetchErrors(url, this.token, controller.signal) + .then((data) => { + // Append new errors (deduplicate by id) + const existingIds = new Set(this.errors.map((e) => e.id)); + const newErrors = data.errors.filter((e) => !existingIds.has(e.id)); + this.errors = [...this.errors, ...newErrors]; + this.total = data.total; + // offset already incremented before fetch; keep it + this.clampCursor(); + this.clampViewport(); + this.mode = "list"; + this.invalidate(); + this.requestRender(); + }) + .catch((err) => { + if (err instanceof Error && err.name === "AbortError") return; + // Revert offset on failure + this.offset = this.offset - this.limit; + this.mode = "list"; + const msg = err instanceof Error ? err.message : String(err); + this.notify(`Load more failed: ${msg}`, "error"); + this.invalidate(); + this.requestRender(); + }); + } + + fetchDetail(id: number): void { + if (this.mode === "loading") return; + + this.abortInFlight(); + this.mode = "loading"; + this.detailScrollOffset = 0; + this.invalidate(); + this.requestRender(); + + const url = buildUrl(this.baseUrl, `/api/v1/errors/${id}`); + const controller = new AbortController(); + this.currentAbortController = controller; + + fetchError(url, this.token, controller.signal) + .then((data) => { + this.selectedError = data.error; + this.detailScrollOffset = 0; + this.mode = "detail"; + this.invalidate(); + this.requestRender(); + }) + .catch((err) => { + if (err instanceof Error && err.name === "AbortError") return; + this.mode = "list"; + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("404")) { + this.notify(`Error #${id} not found`, "warning"); + } else { + this.notify(`Failed to load error detail: ${msg}`, "error"); + } + this.invalidate(); + this.requestRender(); + }); + } + + // ── Input handling ───────────────────────────────────────────────────── + + handleInput(data: string): void { + // Global keys (work in any mode) + if (matchesKey(data, Key.escape)) { + if (this.mode === "detail") { + // Back to list + this.mode = "list"; + this.selectedError = null; + this.detailScrollOffset = 0; + this.invalidate(); + return; + } + this.onClose?.(); + return; + } + + if (matchesKey(data, "q")) { + if (this.mode !== "detail") { + this.onClose?.(); + return; + } + } + + // ── List mode keys ──────────────────────────────────────────────── + + if (this.mode === "list" || this.mode === "loading") { + if (matchesKey(data, "r")) { + this.toggleResolved(); + return; + } + + if (matchesKey(data, "m")) { + this.toggleMuted(); + return; + } + + if (matchesKey(data, "l")) { + if (this.offset + this.limit >= this.total) return; + this.loadMore(); + return; + } + + if (matchesKey(data, Key.enter)) { + if (this.errors.length > 0 && this.cursorIndex >= 0) { + const error = this.errors[this.cursorIndex]; + if (error) { + this.fetchDetail(error.id); + } + } + return; + } + + if (this.mode !== "list") return; // block navigation while loading + + let moved = false; + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.cursorIndex--; + moved = true; + } else if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + this.cursorIndex++; + moved = true; + } else if (matchesKey(data, Key.pageUp)) { + const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines); + this.cursorIndex -= pageSize; + moved = true; + } else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) { + const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines); + this.cursorIndex += pageSize; + moved = true; + } else if (matchesKey(data, Key.ctrl("b"))) { + const pageSize = Math.floor(this.visibleHeight / this.errorEntryLines); + this.cursorIndex -= pageSize; + moved = true; + } else if (matchesKey(data, Key.home) || matchesKey(data, "g")) { + this.cursorIndex = 0; + moved = true; + } else if (matchesKey(data, Key.end) || matchesKey(data, "G")) { + this.cursorIndex = this.errors.length - 1; + moved = true; + } + + if (moved) { + this.clampCursor(); + this.clampViewport(); + this.invalidate(); + } + + return; + } + + // ── Detail mode keys ─────────────────────────────────────────────── + + if (this.mode === "detail") { + if (matchesKey(data, Key.enter)) { + // Copy current line to editor (prod-logs pattern) + if (this.selectedError) { + const detailLines = this.buildDetailLines(); + const idx = this.detailScrollOffset; + if (idx < detailLines.length) { + this.onCopy?.(detailLines[idx]!); + } + } + return; + } + + let moved = false; + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.detailScrollOffset--; + moved = true; + } else if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + this.detailScrollOffset++; + moved = true; + } else if (matchesKey(data, Key.pageUp)) { + this.detailScrollOffset -= this.visibleHeight; + moved = true; + } else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) { + this.detailScrollOffset += this.visibleHeight; + moved = true; + } else if (matchesKey(data, Key.ctrl("b"))) { + this.detailScrollOffset -= this.visibleHeight; + moved = true; + } else if (matchesKey(data, Key.home) || matchesKey(data, "g")) { + this.detailScrollOffset = 0; + moved = true; + } else if (matchesKey(data, Key.end) || matchesKey(data, "G")) { + this.detailScrollOffset = Math.max(0, this.buildDetailLines().length - 1); + moved = true; + } + + if (moved) { + this.detailScrollOffset = Math.max( + 0, + Math.min(this.detailScrollOffset, Math.max(0, this.buildDetailLines().length - 1)), + ); + this.invalidate(); + } + + return; + } + } + + // ── Rendering ────────────────────────────────────────────────────────── + + render(width: number, theme: Theme): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + let result: string[]; + switch (this.mode) { + case "loading": + result = this.renderLoading(width, theme); + break; + case "detail": + result = this.renderDetail(width, theme); + break; + default: + result = this.renderList(width, theme); + break; + } + + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + + private renderLoading(width: number, theme: Theme): string[] { + const border = theme.fg("accent", "─".repeat(width)); + const rows = process.stdout.rows; + const result: string[] = [border]; + const mid = Math.floor((rows - 1) / 2); + + for (let i = 1; i < rows - 1; i++) { + if (i === mid) { + result.push(truncateToWidth(theme.fg("muted", " Loading…"), width, "")); + } else { + result.push(""); + } + } + + result.push(border); + return result; + } + + private renderList(width: number, theme: Theme): string[] { + const border = theme.fg("accent", "─".repeat(width)); + const result: string[] = []; + + // Top border + result.push(border); + + // Header with counts + const unresolvedCount = this.errors.filter( + (e) => e.status === "unresolved", + ).length; + const headerText = ` Production Errors (${this.total} total${unresolvedCount > 0 ? `, ${unresolvedCount} unresolved` : ""})`; + result.push( + truncateToWidth(theme.fg("accent", theme.bold(headerText)), width, ""), + ); + + // Divider + result.push(truncateToWidth(theme.fg("dim", "─".repeat(width)), width, "")); + + // Empty states + if (this.errors.length === 0) { + const msg = + this.totalUnfiltered > 0 + ? "No errors match the current filters" + : "No production errors found"; + result.push(truncateToWidth(theme.fg("muted", ` ${msg}`), width, "")); + // Pad remaining viewport + while (result.length < this.visibleHeight + 3) { + result.push(""); + } + } else { + // Render errors within viewport + const maxEntries = Math.floor(this.visibleHeight / this.errorEntryLines); + const endIdx = Math.min( + this.scrollOffset + maxEntries, + this.errors.length, + ); + + let linesRendered = 0; + for (let i = this.scrollOffset; i < endIdx; i++) { + const error = this.errors[i]; + if (!error) continue; + + const isCursor = i === this.cursorIndex; + const cursor = isCursor ? "▶" : " "; + + // Status badge color + let statusColor = "muted"; + if (error.status === "unresolved") statusColor = "error"; + else if (error.status === "resolved") statusColor = "success"; + + const mutedLabel = error.muted ? " [MUTED]" : ""; + const statusBadge = theme.fg(statusColor, `[${error.status.toUpperCase()}]`); + + // Line 1: cursor + status badge + kind + truncated reason + const reasonText = truncateReason(error.reason, Math.max(30, width - 25)); + const line1 = `${cursor} ${statusBadge}${mutedLabel} ${error.kind}: ${reasonText}`; + result.push(truncateToWidth(isCursor ? theme.fg("accent", line1) : line1, width, "")); + + // Line 2: source info + const sourceInfo = error.source_function + ? ` ${error.source_function} ${error.source_line || ""}` + : ` ${error.source_line || "(no source)"}`; + result.push(truncateToWidth(theme.fg("dim", sourceInfo), width, "")); + + // Line 3: last seen relative time + const lastSeen = error.last_occurrence_at + ? ` Last seen ${formatRelativeTime(error.last_occurrence_at)}` + : " No occurrences"; + result.push(truncateToWidth(theme.fg("dim", lastSeen), width, "")); + + linesRendered += this.errorEntryLines; + } + + // Pad remaining viewport if fewer lines than visible + const targetLines = 3 + this.visibleHeight; + while (result.length < targetLines) { + result.push(""); + } + } + + // Help lines + const resolvedLabel = this.showResolved ? "hide" : "show"; + const mutedLabel = this.showMuted ? "hide" : "show"; + const hasMore = this.offset + this.limit < this.total; + + result.push( + truncateToWidth( + theme.fg( + "dim", + ` ↑↓/jk navigate ↵ details r ${resolvedLabel} resolved m ${mutedLabel} muted`, + ), + width, + "", + ), + ); + result.push( + truncateToWidth( + theme.fg( + "dim", + hasMore + ? ` l load more q quit` + : ` — end of results — q quit`, + ), + width, + "", + ), + ); + + // Bottom border + result.push(border); + + return result; + } + + private buildDetailLines(): string[] { + const error = this.selectedError; + if (!error) return ["(no error selected)"]; + + const lines: string[] = []; + const sep = "─".repeat(40); + + lines.push(`Error #${error.id}: ${error.kind}`); + lines.push(sep); + lines.push(`Reason: ${error.reason}`); + lines.push(`Status: ${error.status} | Muted: ${error.muted}`); + lines.push( + `Source: ${error.source_line || "—"} — ${error.source_function || "—"}`, + ); + lines.push(`Fingerprint: ${error.fingerprint || "—"}`); + lines.push(`First occurrence: ${error.first_occurrence_at ?? "N/A"}`); + lines.push(`Last occurrence: ${error.last_occurrence_at}`); + lines.push(`Total occurrences: ${error.occurrence_count}`); + lines.push(""); + + const occurrences = error.occurrences ?? []; + lines.push(`Occurrences (${occurrences.length}):`); + lines.push(sep); + + if (occurrences.length === 0) { + lines.push(" No occurrences recorded"); + } else { + for (let i = 0; i < occurrences.length; i++) { + const occ = occurrences[i]!; + lines.push(`#${i + 1} ${occ.inserted_at}`); + lines.push(` Reason: ${occ.reason}`); + + if (occ.context && Object.keys(occ.context).length > 0) { + lines.push(` Context:`); + for (const [k, v] of Object.entries(occ.context)) { + lines.push(` ${k}: ${JSON.stringify(v)}`); + } + } + + if (occ.breadcrumbs && occ.breadcrumbs.length > 0) { + lines.push(` Breadcrumbs:`); + for (const crumb of occ.breadcrumbs) { + lines.push(` • ${crumb}`); + } + } + + if (occ.stacktrace?.lines && occ.stacktrace.lines.length > 0) { + lines.push(` Stacktrace:`); + for (const sl of occ.stacktrace.lines) { + const app = sl.application || "—"; + const loc = sl.file + ? `${sl.file}${sl.line != null ? `:${sl.line}` : ""}` + : "(nofile)"; + lines.push( + ` ${app} / ${sl.module}.${sl.function}/${sl.arity} ${loc}`, + ); + } + } + + if (i < occurrences.length - 1) { + lines.push(""); + } + } + } + + return lines; + } + + private renderDetail(width: number, theme: Theme): string[] { + const border = theme.fg("accent", "─".repeat(width)); + const result: string[] = []; + + // Top border + result.push(border); + + // Header + const error = this.selectedError; + const headerText = error + ? ` Error #${error.id}: ${error.kind}` + : " Error Detail"; + result.push( + truncateToWidth(theme.fg("accent", theme.bold(headerText)), width, ""), + ); + + // Divider + result.push(truncateToWidth(theme.fg("dim", "─".repeat(width)), width, "")); + + // Detail content (scrolled) + const detailLines = this.buildDetailLines(); + const endIdx = Math.min( + this.detailScrollOffset + this.visibleHeight, + detailLines.length, + ); + + for (let i = this.detailScrollOffset; i < endIdx; i++) { + result.push(truncateToWidth(` ${detailLines[i]}`, width, "")); + } + + // Pad remaining viewport + const targetLines = 3 + this.visibleHeight; + while (result.length < targetLines) { + result.push(""); + } + + // Help line + const fromLine = this.detailScrollOffset + 1; + const toLine = Math.min( + this.detailScrollOffset + this.visibleHeight, + detailLines.length, + ); + const pct = + detailLines.length > 0 + ? Math.round((this.detailScrollOffset / detailLines.length) * 100) + : 0; + result.push( + truncateToWidth( + theme.fg( + "dim", + ` Lines ${fromLine}-${toLine} of ${detailLines.length} (${pct}%) ↑↓/jk scroll Enter copy Escape back`, + ), + width, + "", + ), + ); + + // Bottom border + result.push(border); + + return result; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + // ── Extension ─────────────────────────────────────────────────────────────── export default function prodErrorsExtension(pi: ExtensionAPI) { @@ -552,4 +1289,108 @@ export default function prodErrorsExtension(pi: ExtensionAPI) { }; }, }); + + // ── /prod-errors interactive command ────────────────────────────────── + + pi.registerCommand("prod-errors", { + description: + "Browse production errors interactively via the /api/v1/errors JSON API", + handler: async (_args, ctx) => { + // ── Validate credentials ──────────────────────────────────────── + + const token = resolveVar("api_token"); + const fqdn = resolveVar("service_fqdn_web"); + + const missing: string[] = []; + if (!token) missing.push("PI_API_TOKEN"); + if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB"); + + if (missing.length > 0) { + ctx.ui.notify( + `Missing environment variables: ${missing.join(", ")}`, + "error", + ); + return; + } + + // ── Fetch initial errors with loader UI ───────────────────────── + + const errorsData = await ctx.ui.custom( + (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"); + }, + }); } diff --git a/.pi/extensions/prod-errors/package.json b/.pi/extensions/prod-errors/package.json index 24ff4f06..6ff8133e 100644 --- a/.pi/extensions/prod-errors/package.json +++ b/.pi/extensions/prod-errors/package.json @@ -1,5 +1,5 @@ { "name": "prod-errors", "private": true, - "description": "Fetch production errors via the JSON API for LLM analysis" + "description": "Fetch production errors via JSON API for LLM analysis, with interactive /prod-errors TUI" } diff --git a/backlog/tasks/ml-164 - Create-pi-extension-for-interactive-error-browsing.md b/backlog/completed/ml-164 - Create-pi-extension-for-interactive-error-browsing.md similarity index 89% rename from backlog/tasks/ml-164 - Create-pi-extension-for-interactive-error-browsing.md rename to backlog/completed/ml-164 - Create-pi-extension-for-interactive-error-browsing.md index 9883e5b9..22c65250 100644 --- a/backlog/tasks/ml-164 - Create-pi-extension-for-interactive-error-browsing.md +++ b/backlog/completed/ml-164 - Create-pi-extension-for-interactive-error-browsing.md @@ -1,13 +1,18 @@ --- id: ML-164 title: Create pi extension for interactive error browsing -status: To Do +status: Done assignee: [] created_date: '2026-05-04 08:08' -updated_date: '2026-05-04 10:03' +updated_date: '2026-05-04 12:44' labels: - pi + - ready dependencies: [] +modified_files: + - .pi/extensions/prod-errors/index.ts + - .pi/extensions/prod-errors/package.json + - docs/production-infrastructure.md parent_task_id: ML-161 priority: medium ordinal: 6000 @@ -282,3 +287,28 @@ No new changes beyond ML-162/ML-163: `PI_API_TOKEN` and `PI_SERVICE_FQDN_WEB` ar - `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui`: Already available. - No new npm dependencies. + +## Implementation Notes + + +## Implementation Notes + +### What was built + +Added the `/prod-errors` interactive TUI command to the existing `.pi/extensions/prod-errors/` extension (which already contained the `fetch_production_errors` and `fetch_production_error` LLM tools from ML-163). + +### Files changed + +- `.pi/extensions/prod-errors/index.ts` — Added ErrorBrowser class (~540 lines), Theme interface, formatRelativeTime/truncateReason helpers, and `/prod-errors` command registration. Total file grew from ~360 to ~1396 lines. +- `.pi/extensions/prod-errors/package.json` — Updated description to mention TUI command. +- `docs/production-infrastructure.md` — Added `/prod-errors` to the pi coding agent tools table and added command description. + +### Key design decisions + +1. **Reuses existing HTTP functions** — `fetchErrors()` and `fetchError()` from ML-163 are reused directly by the ErrorBrowser class. The class builds URLs via the existing `buildUrl()` helper. +2. **Flat rendering (not Container/Text)** — Follows the prod-logs pattern of building arrays of styled strings, not the component-based approach using Container/Text/Box. This is simpler and directly compatible with the `ctx.ui.custom()` API. +3. **Self-contained class** — ErrorBrowser manages all state, navigation, rendering, and async operations internally. It receives `requestRender` and `notify` callbacks from the command handler for I/O. +4. **Occurrence count omission** — The list endpoint doesn't include `occurrence_count` (per ML-162 spec), so the list view shows relative timestamps instead. +5. **No collapsible stacktraces** — Deferred per implementation plan. Initial version shows all stacktraces inline with scrolling. +6. **Explicit load-more** — No auto-load on scroll-to-bottom. Uses `l` key for user-controlled pagination. + diff --git a/docs/production-infrastructure.md b/docs/production-infrastructure.md index 44c148a7..b0b8ca12 100644 --- a/docs/production-infrastructure.md +++ b/docs/production-infrastructure.md @@ -238,13 +238,18 @@ runtime environment (not server-side config). | Extension | Tools | Env vars | |-----------|-------|----------| | `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` | -| `prod-errors` | `fetch_production_errors`, `fetch_production_error` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` | +| `prod-errors` | `fetch_production_errors`, `fetch_production_error`, `/prod-errors` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` | **`prod-logs` env vars:** - `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`) - `PI_COOLIFY_APP_UUID` — Application UUID in Coolify - `PI_COOLIFY_TOKEN` — Coolify API Bearer token +**`prod-errors` tools and command:** +- `fetch_production_errors` — List/filter errors via LLM tool +- `fetch_production_error` — Single error detail via LLM tool +- `/prod-errors` — Interactive TUI for browsing errors (list, detail, filter toggles) + **`prod-errors` env vars:** - `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`) - `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash)