diff --git a/apps/loopover-miner-ui/src/attempt-log-api.test.ts b/apps/loopover-miner-ui/src/attempt-log-api.test.ts new file mode 100644 index 0000000000..bab7d221d1 --- /dev/null +++ b/apps/loopover-miner-ui/src/attempt-log-api.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ATTEMPT_LOG_API_PATH, fetchAttemptLog, type AttemptLogSummary } from "./lib/attempt-log"; +import { + attemptLogApiPlugin, + emptyAttemptLogSummary, + handleAttemptLogRequest, + type AttemptLogApiDeps, +} from "../vite-attempt-log-api"; + +// Raw store rows carrying excluded raw columns (the attempt log's free-text `payload`/`mode`/`reason`, and a junk +// `payload` on an outcome record) the summary must NEVER republish. The API structurally omits these fields, so +// whatever they contain — including any secret — cannot surface; the sentinels below are deliberately +// NON-secret-shaped so the repo's own secret scanner never trips, while still proving the raw fields are dropped. +const rawAttemptRows = [ + { + attemptId: "att-1", + eventType: "attempt_started", + actionClass: "plan", + mode: "auto", + reason: "LEAK_CANARY_REASON_A", + provider: null, + costUsd: null, + tokensUsed: null, + createdAt: "t1", + payload: { detail: "LEAK_CANARY_ATTEMPT_A" }, + }, + { + attemptId: "att-1", + eventType: "attempt_succeeded", + actionClass: "code_edit", + mode: "auto", + reason: "LEAK_CANARY_REASON_B", + provider: "claude-code", + costUsd: 0.05, + tokensUsed: 1200, + createdAt: "t2", + payload: { detail: "LEAK_CANARY_ATTEMPT_B" }, + }, +]; + +const rawOutcomeRecords = [ + { + repoFullName: "acme/widgets", + prNumber: 12, + decision: "merged", + reason: null, + closedAt: "t1", + payload: { detail: "LEAK_CANARY_PR_A" }, + }, + { + repoFullName: "acme/widgets", + prNumber: 13, + decision: "closed", + reason: "insufficient_test_coverage", + closedAt: "t2", + payload: { detail: "LEAK_CANARY_PR_B" }, + }, +]; + +const fixtureSummary: AttemptLogSummary = { + attempts: { + total: 2, + byActionClass: { plan: 1, code_edit: 1 }, + byEventType: { attempt_started: 1, attempt_succeeded: 1 }, + totalCostUsd: 0.05, + recent: [ + { + attemptId: "att-1", + eventType: "attempt_succeeded", + actionClass: "code_edit", + provider: "claude-code", + costUsd: 0.05, + tokensUsed: 1200, + createdAt: "t2", + }, + { + attemptId: "att-1", + eventType: "attempt_started", + actionClass: "plan", + provider: null, + costUsd: null, + tokensUsed: null, + createdAt: "t1", + }, + ], + }, + prOutcomes: { + total: 2, + byDecision: { merged: 1, closed: 1 }, + byReason: { insufficient_test_coverage: 1 }, + recent: [ + { + repoFullName: "acme/widgets", + prNumber: 13, + decision: "closed", + reason: "insufficient_test_coverage", + closedAt: "t2", + }, + { repoFullName: "acme/widgets", prNumber: 12, decision: "merged", reason: null, closedAt: "t1" }, + ], + }, +}; + +describe("emptyAttemptLogSummary (#7656)", () => { + it("summarizes an empty attempt log + outcome store to zeros/null", () => { + expect(emptyAttemptLogSummary()).toEqual({ + attempts: { total: 0, byActionClass: {}, byEventType: {}, totalCostUsd: null, recent: [] }, + prOutcomes: { total: 0, byDecision: { merged: 0, closed: 0 }, byReason: {}, recent: [] }, + }); + }); +}); + +describe("handleAttemptLogRequest (#7656)", () => { + function deps(overrides: Partial = {}): AttemptLogApiDeps { + return { + loadAttemptLogModule: async () => ({ + resolveAttemptLogDbPath: () => "/home/miner/.config/loopover-miner/attempt-log.sqlite3", + readAttemptLogEvents: () => rawAttemptRows, + }), + loadEventLedgerModule: async () => ({ + resolveEventLedgerDbPath: () => "/home/miner/.config/loopover-miner/event-ledger.sqlite3", + readEvents: () => [], + }), + loadPrOutcomeModule: async () => ({ + readPrOutcomes: (reader) => { + // Exercise the reader wiring the handler builds from the event ledger's readEvents export. + reader.readEvents(); + return new Map(rawOutcomeRecords.map((record) => [`${record.repoFullName}:${record.prNumber}`, record])); + }, + }), + fileExists: () => true, + ...overrides, + }; + } + + it("aggregates the attempt log and PR outcomes to counts plus a safe recent feed", async () => { + const handled = await handleAttemptLogRequest("GET", "/api/attempt-log", deps()); + expect(handled?.status).toBe(200); + const body = JSON.parse(handled?.body ?? "{}") as { summary: AttemptLogSummary }; + expect(body.summary).toEqual(fixtureSummary); + }); + + it("INVARIANT (canary): never republishes the raw attempt payload/mode/reason or any raw outcome payload", async () => { + const handled = await handleAttemptLogRequest("GET", "/api/attempt-log", deps()); + const body = handled?.body ?? ""; + for (const forbidden of [ + "LEAK_CANARY_ATTEMPT_A", + "LEAK_CANARY_ATTEMPT_B", + "LEAK_CANARY_REASON_A", + "LEAK_CANARY_REASON_B", + "LEAK_CANARY_PR_A", + "LEAK_CANARY_PR_B", + "payload", + "mode", + "detail", + ]) { + expect(body).not.toContain(forbidden); + } + }); + + it("serves an empty summary on a fresh install WITHOUT initializing any store", async () => { + let attemptsRead = false; + let outcomesRead = false; + const handled = await handleAttemptLogRequest( + "GET", + "/api/attempt-log", + deps({ + fileExists: () => false, + loadAttemptLogModule: async () => ({ + resolveAttemptLogDbPath: () => "/nowhere/attempt-log.sqlite3", + readAttemptLogEvents: () => { + attemptsRead = true; + return rawAttemptRows; + }, + }), + loadPrOutcomeModule: async () => ({ + readPrOutcomes: () => { + outcomesRead = true; + return new Map(); + }, + }), + }), + ); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ summary: emptyAttemptLogSummary() }) }); + expect(attemptsRead).toBe(false); + expect(outcomesRead).toBe(false); + }); + + it("falls through (null) for other paths and non-GET methods", async () => { + expect(await handleAttemptLogRequest("GET", "/api/ledgers", deps())).toBeNull(); + expect(await handleAttemptLogRequest("POST", "/api/attempt-log", deps())).toBeNull(); + }); + + it("surfaces a store read failure as a 500 with a safe message", async () => { + const handled = await handleAttemptLogRequest( + "GET", + "/api/attempt-log", + deps({ + loadAttemptLogModule: async () => { + throw new Error("sqlite locked"); + }, + }), + ); + expect(handled).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) }); + }); + + it("surfaces a non-Error throw as a 500 with a safe fallback message", async () => { + const handled = await handleAttemptLogRequest( + "GET", + "/api/attempt-log", + deps({ + loadAttemptLogModule: async () => { + throw "nope"; + }, + }), + ); + expect(handled).toEqual({ status: 500, body: JSON.stringify({ error: "failed to read the local attempt log" }) }); + }); +}); + +describe("attemptLogApiPlugin middleware (#7656)", () => { + it("serves a matching request and passes every other request through to next()", async () => { + const plugin = attemptLogApiPlugin({ + loadAttemptLogModule: async () => ({ + resolveAttemptLogDbPath: () => "/db/attempt-log.sqlite3", + readAttemptLogEvents: () => rawAttemptRows, + }), + loadEventLedgerModule: async () => ({ + resolveEventLedgerDbPath: () => "/db/event-ledger.sqlite3", + readEvents: () => [], + }), + loadPrOutcomeModule: async () => ({ readPrOutcomes: () => new Map() }), + fileExists: () => true, + }); + let middleware: ((req: unknown, res: unknown, next: () => void) => void) | undefined; + const server = { middlewares: { use: (fn: typeof middleware) => (middleware = fn) } }; + (plugin.configureServer as (s: unknown) => void)(server); + (plugin.configurePreviewServer as (s: unknown) => void)(server); + expect(middleware).toBeTypeOf("function"); + + const next = vi.fn(); + middleware!({ method: "GET", url: "/api/other" }, {}, next); + await vi.waitFor(() => expect(next).toHaveBeenCalledTimes(1)); + + const res = { statusCode: 0, setHeader: vi.fn(), end: vi.fn() }; + await new Promise((resolve) => { + res.end = vi.fn(() => resolve()); + middleware!({ method: "GET", url: "/api/attempt-log" }, res, vi.fn()); + }); + expect(res.statusCode).toBe(200); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/json"); + }); +}); + +describe("fetchAttemptLog (#7656)", () => { + const jsonResponse = (status: number, payload: unknown) => + ({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response; + + it("returns a typed summary from a well-formed payload, requesting the local API path", async () => { + let requested: string | undefined; + const result = await fetchAttemptLog(async (input) => { + requested = String(input); + return jsonResponse(200, { summary: fixtureSummary }); + }); + expect(requested).toBe(ATTEMPT_LOG_API_PATH); + expect(result).toEqual({ ok: true, summary: fixtureSummary }); + }); + + it("surfaces non-2xx, malformed payloads, and thrown fetches as typed errors", async () => { + expect(await fetchAttemptLog(async () => jsonResponse(500, {}))).toEqual({ + ok: false, + error: "local attempt-log API responded 500", + }); + expect(await fetchAttemptLog(async () => jsonResponse(200, { summary: { attempts: { total: 1 } } }))).toMatchObject( + { + ok: false, + }, + ); + // A malformed recent entry (bad decision) is rejected too. + expect( + await fetchAttemptLog(async () => + jsonResponse(200, { + summary: { + ...emptyAttemptLogSummary(), + prOutcomes: { + total: 1, + byDecision: { merged: 1, closed: 0 }, + byReason: {}, + recent: [{ repoFullName: "a/b", prNumber: 1, decision: "reopened", reason: null, closedAt: null }], + }, + }, + }), + ), + ).toMatchObject({ ok: false }); + expect( + await fetchAttemptLog(async () => { + throw new Error("connection refused"); + }), + ).toEqual({ ok: false, error: "connection refused" }); + expect( + await fetchAttemptLog(async () => { + throw "x"; + }), + ).toEqual({ ok: false, error: "failed to reach the local attempt-log API" }); + }); + + it("#5963: in demo mode, returns a canned summary without ever calling fetch", async () => { + vi.stubEnv("VITE_DEMO_MODE", "1"); + let called = false; + const result = await fetchAttemptLog(async () => { + called = true; + return jsonResponse(200, { summary: fixtureSummary }); + }); + expect(called).toBe(false); + expect(result.ok).toBe(true); + vi.unstubAllEnvs(); + }); +}); diff --git a/apps/loopover-miner-ui/src/attempts.test.tsx b/apps/loopover-miner-ui/src/attempts.test.tsx new file mode 100644 index 0000000000..d30900c1c6 --- /dev/null +++ b/apps/loopover-miner-ui/src/attempts.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AttemptLogResult, AttemptLogSummary } from "./lib/attempt-log"; +import { AttemptLogView, AttemptsPage } from "./routes/attempts"; + +const emptySummary = (): AttemptLogSummary => ({ + attempts: { total: 0, byActionClass: {}, byEventType: {}, totalCostUsd: null, recent: [] }, + prOutcomes: { total: 0, byDecision: { merged: 0, closed: 0 }, byReason: {}, recent: [] }, +}); + +const fixtureSummary: AttemptLogSummary = { + attempts: { + total: 2, + byActionClass: { code_edit: 1, plan: 1 }, + byEventType: { attempt_started: 1, attempt_succeeded: 1 }, + totalCostUsd: 0.05, + recent: [ + { + attemptId: "att-1", + eventType: "attempt_succeeded", + actionClass: "code_edit", + provider: "claude-code", + costUsd: 0.05, + tokensUsed: 1200, + createdAt: "2026-07-18T14:01:00.000Z", + }, + { + attemptId: "att-1", + eventType: "attempt_started", + actionClass: "plan", + provider: null, + costUsd: null, + tokensUsed: null, + createdAt: null, + }, + ], + }, + prOutcomes: { + total: 2, + byDecision: { merged: 1, closed: 1 }, + byReason: { insufficient_test_coverage: 1 }, + recent: [ + { + repoFullName: "acme/widgets", + prNumber: 13, + decision: "closed", + reason: "insufficient_test_coverage", + closedAt: "2026-07-18T13:40:00.000Z", + }, + { repoFullName: null, prNumber: null, decision: "merged", reason: null, closedAt: null }, + ], + }, +}; + +function manyCounts(count: number): Record { + return Object.fromEntries(Array.from({ length: count }, (_, index) => [`action_${index}`, count - index])); +} + +describe("AttemptLogView (#7656)", () => { + it("renders action/event counts, the total cost, the recent-attempts feed, and PR-outcome decisions", () => { + render(); + expect(screen.getByText("Attempts (2)")).toBeTruthy(); + expect(screen.getAllByText("$0.0500").length).toBeGreaterThan(0); + // action classes appear in the "by action class" table and one also in the recent feed. + expect(screen.getAllByText("code_edit").length).toBeGreaterThan(0); + expect(screen.getAllByText("att-1").length).toBe(2); + expect(screen.getByText("claude-code")).toBeTruthy(); + expect(screen.getByText("Merged", { selector: "dt" }).nextSibling?.textContent).toBe("1"); + expect(screen.getByText("Closed", { selector: "dt" }).nextSibling?.textContent).toBe("1"); + expect(screen.getAllByText("insufficient_test_coverage").length).toBeGreaterThan(0); + expect(screen.getByText("#13")).toBeTruthy(); + // Null columns render as em-dashes (provider, cost, tokens, repo, PR). + expect(screen.getAllByText("—").length).toBeGreaterThan(0); + }); + + it("renders the fresh-install empty state when both stores are empty", () => { + render(); + expect(screen.getByText(/No attempts yet/i)).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); + }); + + it("renders an error message when the local API is unreachable", () => { + render(); + expect(screen.getByRole("alert").textContent).toContain("connection refused"); + }); + + it("renders a content-shaped loading skeleton (role=status)", () => { + render(); + expect(screen.getByRole("status", { name: /loading local attempt log/i })).toBeTruthy(); + }); + + it("paginates a count table client-side above 20 rows", () => { + const summary: AttemptLogSummary = { + ...emptySummary(), + attempts: { total: 45, byActionClass: manyCounts(45), byEventType: {}, totalCostUsd: null, recent: [] }, + }; + render(); + expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy(); + expect(screen.getByText("action_0")).toBeTruthy(); + expect(screen.queryByText("action_20")).toBeNull(); + fireEvent.click(screen.getByRole("link", { name: "2" })); + expect(screen.getByText("action_20")).toBeTruthy(); + expect(screen.queryByText("action_0")).toBeNull(); + // Previous / Next also move the page. + fireEvent.click(screen.getByRole("link", { name: /go to previous page/i })); + expect(screen.getByText("action_0")).toBeTruthy(); + fireEvent.click(screen.getByRole("link", { name: /go to next page/i })); + expect(screen.getByText("action_20")).toBeTruthy(); + }); +}); + +describe("AttemptsPage (#7656)", () => { + it("loads the summary through the injected loader and renders it", async () => { + const loadAttemptLog = async (): Promise => ({ ok: true, summary: fixtureSummary }); + render(); + expect(screen.getByRole("heading", { name: "Attempts" })).toBeTruthy(); + await waitFor(() => expect(screen.getByText("Attempts (2)")).toBeTruthy()); + }); + + describe("live refresh (#7656)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("re-polls the summary on the shared cadence, without any user action", async () => { + vi.useFakeTimers(); + const loadAttemptLog = vi.fn(async (): Promise => ({ ok: true, summary: fixtureSummary })); + render(); + await vi.waitFor(() => expect(loadAttemptLog).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(loadAttemptLog).toHaveBeenCalledTimes(2)); + }); + }); +}); diff --git a/apps/loopover-miner-ui/src/lib/attempt-log.ts b/apps/loopover-miner-ui/src/lib/attempt-log.ts new file mode 100644 index 0000000000..09c91b0aa8 --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/attempt-log.ts @@ -0,0 +1,132 @@ +// Read-only client for the local attempt-log API (#7656). The middleware aggregates the miner's per-attempt event +// log and its PR-outcome records server-side into action/type/decision counts plus a small feed of SAFE columns — +// it never republishes the attempt log's raw `payload` or any secret-shaped value (the same invariant the sibling +// ledgers client and the read-only MCP tools enforce). This client just fetches that summary and validates its +// shape; a failure surfaces as a typed error result the view renders, never a crash. + +import { DEMO_ATTEMPT_LOG_SUMMARY, isDemoMode } from "./demo-data"; + +export const ATTEMPT_LOG_API_PATH = "/api/attempt-log"; + +export const PR_OUTCOME_DECISIONS = ["merged", "closed"] as const; +export type PrOutcomeDecision = (typeof PR_OUTCOME_DECISIONS)[number]; + +export type AttemptFeedEntry = { + attemptId: string; + eventType: string; + actionClass: string; + provider: string | null; + costUsd: number | null; + tokensUsed: number | null; + createdAt: string | null; +}; +export type PrOutcomeFeedEntry = { + repoFullName: string | null; + prNumber: number | null; + decision: PrOutcomeDecision; + reason: string | null; + closedAt: string | null; +}; +export type AttemptsSummary = { + total: number; + byActionClass: Record; + byEventType: Record; + totalCostUsd: number | null; + recent: AttemptFeedEntry[]; +}; +export type PrOutcomesSummary = { + total: number; + byDecision: Record; + byReason: Record; + recent: PrOutcomeFeedEntry[]; +}; +export type AttemptLogSummary = { attempts: AttemptsSummary; prOutcomes: PrOutcomesSummary }; + +export type AttemptLogResult = { ok: true; summary: AttemptLogSummary } | { ok: false; error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isCountMap(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((count) => typeof count === "number"); +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || typeof value === "number"; +} + +function isAttemptFeedEntry(value: unknown): value is AttemptFeedEntry { + if (!isRecord(value)) return false; + return ( + typeof value.attemptId === "string" && + typeof value.eventType === "string" && + typeof value.actionClass === "string" && + isNullableString(value.provider) && + isNullableNumber(value.costUsd) && + isNullableNumber(value.tokensUsed) && + isNullableString(value.createdAt) + ); +} + +function isPrOutcomeFeedEntry(value: unknown): value is PrOutcomeFeedEntry { + if (!isRecord(value)) return false; + return ( + isNullableString(value.repoFullName) && + isNullableNumber(value.prNumber) && + (value.decision === "merged" || value.decision === "closed") && + isNullableString(value.reason) && + isNullableString(value.closedAt) + ); +} + +function isAttemptsSummary(value: unknown): value is AttemptsSummary { + if (!isRecord(value)) return false; + return ( + typeof value.total === "number" && + isCountMap(value.byActionClass) && + isCountMap(value.byEventType) && + isNullableNumber(value.totalCostUsd) && + Array.isArray(value.recent) && + value.recent.every(isAttemptFeedEntry) + ); +} + +function isPrOutcomesSummary(value: unknown): value is PrOutcomesSummary { + if (!isRecord(value)) return false; + const byDecision = value.byDecision; + return ( + typeof value.total === "number" && + isRecord(byDecision) && + typeof byDecision.merged === "number" && + typeof byDecision.closed === "number" && + isCountMap(value.byReason) && + Array.isArray(value.recent) && + value.recent.every(isPrOutcomeFeedEntry) + ); +} + +function isAttemptLogSummary(value: unknown): value is AttemptLogSummary { + if (!isRecord(value)) return false; + return isAttemptsSummary(value.attempts) && isPrOutcomesSummary(value.prOutcomes); +} + +/** Fetch the local attempt-log summary; failures surface as a typed error result the view renders, never a crash. */ +export async function fetchAttemptLog(fetchImpl: typeof fetch = fetch): Promise { + if (isDemoMode()) return { ok: true, summary: DEMO_ATTEMPT_LOG_SUMMARY }; + try { + const response = await fetchImpl(ATTEMPT_LOG_API_PATH); + if (!response.ok) return { ok: false, error: `local attempt-log API responded ${response.status}` }; + const payload: unknown = await response.json(); + const summary = (payload as { summary?: unknown }).summary; + if (!isAttemptLogSummary(summary)) + return { ok: false, error: "local attempt-log API returned an unexpected payload shape" }; + return { ok: true, summary }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "failed to reach the local attempt-log API" }; + } +} diff --git a/apps/loopover-miner-ui/src/lib/demo-data.ts b/apps/loopover-miner-ui/src/lib/demo-data.ts index 393a67146e..cbb08612ee 100644 --- a/apps/loopover-miner-ui/src/lib/demo-data.ts +++ b/apps/loopover-miner-ui/src/lib/demo-data.ts @@ -5,15 +5,16 @@ // build-time constant, so the "off" branch (the real fetch calls) is dead-code-eliminated from a demo bundle and // vice versa -- a production self-host build never carries this module's data. // -// Scope: the six REST fetchers backing the four main dashboard routes (run-history, ledgers, portfolio + its -// queue actions, governor, ranked-candidates (#7675)). discover/attempt/chat are NOT covered here -- those -// trigger a real coding-agent iteration or ground against a live MCP connection, and fabricating a convincing -// multi-minute agent run is a separate, much larger content-design task than tabular summary data (tracked as -// follow-up work, not this PR). +// Scope: the REST fetchers backing the main tabular dashboard routes (run-history, ledgers, portfolio + its +// queue actions, governor, ranked-candidates (#7675), attempt-log (#7656)). discover/attempt/chat are NOT covered +// here -- those trigger a real coding-agent iteration or ground against a live MCP connection, and fabricating a +// convincing multi-minute agent run is a separate, much larger content-design task than tabular summary data +// (tracked as follow-up work, not this PR). // // Every value below is entirely synthetic -- no real repo, run, ledger entry, or account referenced anywhere. import type { RunStateRow } from "./run-history"; +import type { AttemptLogSummary } from "./attempt-log"; import type { LedgersSummary } from "./ledgers"; import type { PortfolioQueueSummary } from "./portfolio-queue"; import type { PortfolioQueueActionItem } from "./portfolio-queue-actions"; @@ -109,6 +110,109 @@ export const DEMO_LEDGERS_SUMMARY: LedgersSummary = { governor: { total: 9, byEventType: { paused: 4, resumed: 5 } }, }; +// Synthetic per-attempt history + PR-outcome roll-up (#7656). Entirely fabricated: only the four demo repos, the +// engine's attempt-event vocabulary, and non-secret placeholder ids appear. `totalCostUsd` is the sum of the +// recent feed's non-null `costUsd`; byActionClass / byEventType each sum to `attempts.total`. +export const DEMO_ATTEMPT_LOG_SUMMARY: AttemptLogSummary = { + attempts: { + total: 6, + byActionClass: { code_edit: 3, tool_call: 2, plan: 1 }, + byEventType: { attempt_started: 2, tool_used: 2, attempt_succeeded: 1, attempt_failed: 1 }, + totalCostUsd: 0.183, + recent: [ + { + attemptId: "att-2451", + eventType: "attempt_succeeded", + actionClass: "code_edit", + provider: "claude-code", + costUsd: 0.072, + tokensUsed: null, + createdAt: "2026-07-18T14:01:00.000Z", + }, + { + attemptId: "att-2451", + eventType: "tool_used", + actionClass: "tool_call", + provider: "claude-code", + costUsd: 0.041, + tokensUsed: null, + createdAt: "2026-07-18T13:58:00.000Z", + }, + { + attemptId: "att-2438", + eventType: "attempt_failed", + actionClass: "code_edit", + provider: "claude-code", + costUsd: 0.07, + tokensUsed: null, + createdAt: "2026-07-18T13:20:00.000Z", + }, + { + attemptId: "att-2438", + eventType: "attempt_started", + actionClass: "plan", + provider: null, + costUsd: null, + tokensUsed: null, + createdAt: "2026-07-18T13:12:00.000Z", + }, + { + attemptId: "att-2402", + eventType: "tool_used", + actionClass: "tool_call", + provider: "claude-code", + costUsd: null, + tokensUsed: null, + createdAt: "2026-07-18T11:05:00.000Z", + }, + { + attemptId: "att-2402", + eventType: "attempt_started", + actionClass: "code_edit", + provider: null, + costUsd: null, + tokensUsed: null, + createdAt: "2026-07-18T11:02:00.000Z", + }, + ], + }, + prOutcomes: { + total: 4, + byDecision: { merged: 3, closed: 1 }, + byReason: { insufficient_test_coverage: 1 }, + recent: [ + { + repoFullName: "acme/widgets", + prNumber: 2451, + decision: "merged", + reason: null, + closedAt: "2026-07-18T15:10:00.000Z", + }, + { + repoFullName: "acme/api-gateway", + prNumber: 118, + decision: "closed", + reason: "insufficient_test_coverage", + closedAt: "2026-07-18T13:40:00.000Z", + }, + { + repoFullName: "acme/docs-site", + prNumber: 58, + decision: "merged", + reason: null, + closedAt: "2026-07-17T22:05:00.000Z", + }, + { + repoFullName: "northwind/inventory", + prNumber: 77, + decision: "merged", + reason: null, + closedAt: "2026-07-17T18:30:00.000Z", + }, + ], + }, +}; + export const DEMO_PORTFOLIO_QUEUE_SUMMARY: PortfolioQueueSummary = { total: 27, byStatus: { queued: 9, in_progress: 3, done: 15 }, diff --git a/apps/loopover-miner-ui/src/routeTree.gen.ts b/apps/loopover-miner-ui/src/routeTree.gen.ts index 5d7d3f5059..ed988e9660 100644 --- a/apps/loopover-miner-ui/src/routeTree.gen.ts +++ b/apps/loopover-miner-ui/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as RankedCandidatesRouteImport } from './routes/ranked-candidates import { Route as PortfolioRouteImport } from './routes/portfolio' import { Route as LedgersRouteImport } from './routes/ledgers' import { Route as EarningsRouteImport } from './routes/earnings' +import { Route as AttemptsRouteImport } from './routes/attempts' import { Route as IndexRouteImport } from './routes/index' const RunHistoryRoute = RunHistoryRouteImport.update({ @@ -41,6 +42,11 @@ const EarningsRoute = EarningsRouteImport.update({ path: '/earnings', getParentRoute: () => rootRouteImport, } as any) +const AttemptsRoute = AttemptsRouteImport.update({ + id: '/attempts', + path: '/attempts', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -49,6 +55,7 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/attempts': typeof AttemptsRoute '/earnings': typeof EarningsRoute '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute @@ -57,6 +64,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/attempts': typeof AttemptsRoute '/earnings': typeof EarningsRoute '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute @@ -66,6 +74,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/attempts': typeof AttemptsRoute '/earnings': typeof EarningsRoute '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute @@ -76,6 +85,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/attempts' | '/earnings' | '/ledgers' | '/portfolio' @@ -84,6 +94,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/attempts' | '/earnings' | '/ledgers' | '/portfolio' @@ -92,6 +103,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/attempts' | '/earnings' | '/ledgers' | '/portfolio' @@ -101,6 +113,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AttemptsRoute: typeof AttemptsRoute EarningsRoute: typeof EarningsRoute LedgersRoute: typeof LedgersRoute PortfolioRoute: typeof PortfolioRoute @@ -145,6 +158,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof EarningsRouteImport parentRoute: typeof rootRouteImport } + '/attempts': { + id: '/attempts' + path: '/attempts' + fullPath: '/attempts' + preLoaderRoute: typeof AttemptsRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -157,6 +177,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AttemptsRoute: AttemptsRoute, EarningsRoute: EarningsRoute, LedgersRoute: LedgersRoute, PortfolioRoute: PortfolioRoute, diff --git a/apps/loopover-miner-ui/src/routes/__root.tsx b/apps/loopover-miner-ui/src/routes/__root.tsx index 4a906dfb6c..7daa313059 100644 --- a/apps/loopover-miner-ui/src/routes/__root.tsx +++ b/apps/loopover-miner-ui/src/routes/__root.tsx @@ -23,6 +23,7 @@ const NAV_ITEMS = [ { to: "/ranked-candidates", label: "Ranked candidates" }, { to: "/portfolio", label: "Portfolio" }, { to: "/ledgers", label: "Ledgers" }, + { to: "/attempts", label: "Attempts" }, // #7673: layout reservation only — the route is an empty placeholder until settlement data exists. { to: "/earnings", label: "Earnings — not yet available" }, ] as const; diff --git a/apps/loopover-miner-ui/src/routes/attempts.tsx b/apps/loopover-miner-ui/src/routes/attempts.tsx new file mode 100644 index 0000000000..8622bfc79d --- /dev/null +++ b/apps/loopover-miner-ui/src/routes/attempts.tsx @@ -0,0 +1,346 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; + +import { Badge } from "@loopover/ui-kit/components/badge"; +import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@loopover/ui-kit/components/pagination"; +import { Skeleton } from "@loopover/ui-kit/components/skeleton"; +import { StateBoundary } from "@loopover/ui-kit/components/state-views"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table"; + +import { + fetchAttemptLog, + type AttemptFeedEntry, + type AttemptLogResult, + type AttemptLogSummary, + type PrOutcomeDecision, + type PrOutcomeFeedEntry, +} from "../lib/attempt-log"; +import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch"; + +export const Route = createFileRoute("/attempts")({ + component: AttemptsPage, +}); + +// Read-only per-attempt HISTORY over the miner's local attempt-log + PR-outcome stores (#7656). Both are aggregated +// server-side (see vite-attempt-log-api.ts) to action/type/decision counts plus a small feed of SAFE columns — the +// attempt log's raw payload never reaches this component. +// +// DISTINCT from run-history.tsx: that route shows only the CURRENT per-repo run STATE (one live row per repo). This +// route is the log of individual PAST attempts — each attempt's actionClass, provider, cost, tokens, and event +// outcome — plus how the miner's own PRs ultimately resolved (merged/closed). The two answer "what is the miner +// doing now" vs. "what has the miner already done". + +const DECISION_LABELS: Record = { merged: "Merged", closed: "Closed" }; +const DECISION_VARIANT: Record = { + merged: "secondary", + closed: "outline", +}; + +/** Rows per page once a count/feed table grows past this; below it the full table renders unpaginated. */ +const PAGE_SIZE = 20; + +const dashIfNull = (value: string | number | null): string | number => (value === null ? "—" : value); +const formatCost = (costUsd: number | null): string => (costUsd === null ? "—" : `$${costUsd.toFixed(4)}`); + +function TablePagination({ + page, + pageCount, + onPageChange, +}: { + page: number; + pageCount: number; + onPageChange: (next: number) => void; +}) { + return ( + + + + { + event.preventDefault(); + onPageChange(Math.max(0, page - 1)); + }} + /> + + {Array.from({ length: pageCount }).map((_, index) => ( + + { + event.preventDefault(); + onPageChange(index); + }} + > + {index + 1} + + + ))} + + = pageCount - 1} + onClick={(event) => { + event.preventDefault(); + onPageChange(Math.min(pageCount - 1, page + 1)); + }} + /> + + + + ); +} + +/** Generic pageable helper: slices a list to PAGE_SIZE-sized pages, rendering the pager only past the first page. */ +function usePagedRows(rows: T[]): { + visible: T[]; + isPaginated: boolean; + page: number; + pageCount: number; + setPage: (n: number) => void; +} { + const [page, setPage] = useState(0); + const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const isPaginated = rows.length > PAGE_SIZE; + const safePage = Math.min(page, pageCount - 1); + const visible = isPaginated ? rows.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) : rows; + return { visible, isPaginated, page: safePage, pageCount, setPage }; +} + +function CountTable({ counts, keyLabel }: { counts: Record; keyLabel: string }) { + const entries = Object.entries(counts).sort(([, a], [, b]) => b - a); + const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries); + return ( +
+ + + + {keyLabel} + Count + + + + {visible.map(([key, count]) => ( + + {key} + {count} + + ))} + +
+ {isPaginated && } +
+ ); +} + +function RecentAttemptsTable({ entries }: { entries: AttemptFeedEntry[] }) { + const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries); + return ( +
+ + + + Attempt + Event + Action + Provider + Cost + Tokens + Recorded + + + + {visible.map((entry, index) => ( + + {entry.attemptId} + {entry.eventType} + {entry.actionClass} + {dashIfNull(entry.provider)} + {formatCost(entry.costUsd)} + {dashIfNull(entry.tokensUsed)} + {dashIfNull(entry.createdAt)} + + ))} + +
+ {isPaginated && } +
+ ); +} + +function RecentOutcomesTable({ entries }: { entries: PrOutcomeFeedEntry[] }) { + const { visible, isPaginated, page, pageCount, setPage } = usePagedRows(entries); + return ( +
+ + + + Repository + PR + Decision + Reason + Closed + + + + {visible.map((entry, index) => ( + + {dashIfNull(entry.repoFullName)} + {entry.prNumber === null ? "—" : `#${entry.prNumber}`} + + {DECISION_LABELS[entry.decision]} + + {dashIfNull(entry.reason)} + {dashIfNull(entry.closedAt)} + + ))} + +
+ {isPaginated && } +
+ ); +} + +/** Section-shaped loading placeholder mirroring the attempt/outcome section headings + tables, so the layout keeps + * its shape while the first poll resolves. `role="status"` keeps the loading state announced to assistive tech. */ +function AttemptLogSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ + +
+ ))} +
+ ); +} + +function AttemptLogSummaryContent({ summary }: { summary: AttemptLogSummary }) { + const { attempts, prOutcomes } = summary; + return ( +
+
+

Attempts ({attempts.total})

+

+ Total recorded cost: {formatCost(attempts.totalCostUsd)} +

+
+ +
+

Attempts by action class

+ {Object.keys(attempts.byActionClass).length === 0 ? ( +

No attempt events recorded.

+ ) : ( + + )} +
+ +
+

Attempts by event type

+ {Object.keys(attempts.byEventType).length === 0 ? ( +

No attempt events recorded.

+ ) : ( + + )} +
+ +
+

Recent attempts ({attempts.total})

+ {attempts.recent.length === 0 ? ( +

No attempt-log entries recorded.

+ ) : ( + + )} +
+ +
+

PR outcomes ({prOutcomes.total})

+
+ {(["merged", "closed"] as const).map((decision) => ( + + +
+ {DECISION_LABELS[decision]} +
+
+ {prOutcomes.byDecision[decision]} +
+
+
+ ))} +
+ {Object.keys(prOutcomes.byReason).length > 0 && ( + + )} +
+ +
+

Recent PR outcomes ({prOutcomes.total})

+ {prOutcomes.recent.length === 0 ? ( +

No PR outcomes recorded.

+ ) : ( + + )} +
+
+ ); +} + +export function AttemptLogView({ result }: { result: AttemptLogResult | null }) { + const summary = result?.ok ? result.summary : null; + const isEmpty = summary !== null && summary.attempts.total === 0 && summary.prOutcomes.total === 0; + const errorText = result !== null && !result.ok ? result.error : undefined; + return ( + } + errorTitle="Couldn't read the local attempt log" + errorDescription={errorText} + emptyTitle="No attempts yet" + emptyDescription="Per-attempt events and PR outcomes appear here once the miner runs its first attempt." + > + {summary && } + + ); +} + +export function AttemptsPage({ + loadAttemptLog = fetchAttemptLog, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, +}: { + loadAttemptLog?: () => Promise; + pollIntervalMs?: number; +}) { + const { result } = usePolledFetch(loadAttemptLog, pollIntervalMs); + + return ( + + +

Attempts

+

+ Local, read-only history of the miner's individual past attempts and its own PR outcomes. +

+
+ + + +
+ ); +} diff --git a/apps/loopover-miner-ui/vite-attempt-log-api.ts b/apps/loopover-miner-ui/vite-attempt-log-api.ts new file mode 100644 index 0000000000..aca2e84836 --- /dev/null +++ b/apps/loopover-miner-ui/vite-attempt-log-api.ts @@ -0,0 +1,230 @@ +import { existsSync } from "node:fs"; +import type { Plugin } from "vite"; + +// Local read-only attempt-log + PR-outcome API (#7656) — sibling of `vite-ledgers-api.ts` / `vite-run-state-api.ts`, +// same shape and same reason: the dashboard is a browser app while the miner's per-attempt event log +// (`attempt-log.sqlite3`) and its own PR-outcome records (recorded INTO the event ledger) are `node:sqlite` files on +// disk, so the dev server bridges the two by calling into the EXISTING read exports of +// `packages/loopover-miner/lib/{attempt-log,event-ledger,pr-outcome}.js`. +// +// SAFETY: both stores are aggregated SERVER-SIDE to action/type/decision COUNTS plus a small feed of +// explicitly-projected SAFE columns. The attempt log's free-form `payload` (a per-event blob that can carry a +// coding-agent tool call's raw arguments) NEVER crosses the wire — only the fixed, non-free-text columns +// (attemptId, eventType, actionClass, provider, costUsd, tokensUsed, createdAt) are projected. That is the same "no +// raw payload, safe columns only" invariant the sibling ledgers endpoint and the read-only MCP tools enforce (#5199). +// +// Same read-only fresh-install rule as the sibling endpoints: the default `read*` exports lazily initialize their +// store, which would CREATE the SQLite file — so each store's resolved DB path is probed first and reported empty +// without ever touching the store when no DB exists yet. + +const RECENT_EVENT_LIMIT = 25; + +export const PR_OUTCOME_DECISIONS = ["merged", "closed"] as const; +type PrOutcomeDecision = (typeof PR_OUTCOME_DECISIONS)[number]; + +// Raw store rows are read defensively through `unknown` fields (mirrors vite-ledgers-api.ts): only the projected +// columns below are ever touched, so an unexpected extra field — including the excluded `payload` — is structurally +// dropped rather than trusted. +type AttemptLogRow = { + eventType?: unknown; + attemptId?: unknown; + actionClass?: unknown; + provider?: unknown; + costUsd?: unknown; + tokensUsed?: unknown; + createdAt?: unknown; +}; +type PrOutcomeRecord = { + repoFullName?: unknown; + prNumber?: unknown; + decision?: unknown; + reason?: unknown; + closedAt?: unknown; +}; + +type AttemptLogModule = { + resolveAttemptLogDbPath: () => string; + readAttemptLogEvents: (filter?: unknown) => AttemptLogRow[]; +}; +type EventLedgerModule = { resolveEventLedgerDbPath: () => string; readEvents: (filter?: unknown) => unknown[] }; +type PrOutcomeModule = { + readPrOutcomes: ( + reader: { readEvents: (filter?: unknown) => unknown[] }, + filter?: unknown, + ) => Map; +}; + +export type AttemptFeedEntry = { + attemptId: string; + eventType: string; + actionClass: string; + provider: string | null; + costUsd: number | null; + tokensUsed: number | null; + createdAt: string | null; +}; +export type PrOutcomeFeedEntry = { + repoFullName: string | null; + prNumber: number | null; + decision: PrOutcomeDecision; + reason: string | null; + closedAt: string | null; +}; +export type AttemptsSummary = { + total: number; + byActionClass: Record; + byEventType: Record; + totalCostUsd: number | null; + recent: AttemptFeedEntry[]; +}; +export type PrOutcomesSummary = { + total: number; + byDecision: Record; + byReason: Record; + recent: PrOutcomeFeedEntry[]; +}; +export type AttemptLogSummary = { attempts: AttemptsSummary; prOutcomes: PrOutcomesSummary }; + +export function emptyAttemptLogSummary(): AttemptLogSummary { + return { + attempts: { total: 0, byActionClass: {}, byEventType: {}, totalCostUsd: null, recent: [] }, + prOutcomes: { total: 0, byDecision: { merged: 0, closed: 0 }, byReason: {}, recent: [] }, + }; +} + +const asString = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null); +const asNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +function summarizeAttempts(rows: AttemptLogRow[]): AttemptsSummary { + const byActionClass: Record = {}; + const byEventType: Record = {}; + // Null (not 0) when no event carried a real cost — never fabricated (mirrors attempt-log.ts's own costUsd + // contract). `hasCost` separates "no cost-bearing event" (→ null) from "cost-bearing events summing to 0". + let costTotal = 0; + let hasCost = false; + for (const row of rows) { + const actionClass = asString(row.actionClass); + if (actionClass) byActionClass[actionClass] = (byActionClass[actionClass] ?? 0) + 1; + const eventType = asString(row.eventType); + if (eventType) byEventType[eventType] = (byEventType[eventType] ?? 0) + 1; + const cost = asNumber(row.costUsd); + if (cost !== null) { + costTotal += cost; + hasCost = true; + } + } + // Newest-first, capped — and projected to SAFE columns only (never the raw payload). + const recent = rows + .slice(-RECENT_EVENT_LIMIT) + .reverse() + .map((row) => ({ + attemptId: asString(row.attemptId) ?? "unknown", + eventType: asString(row.eventType) ?? "unknown", + actionClass: asString(row.actionClass) ?? "unknown", + provider: asString(row.provider), + costUsd: asNumber(row.costUsd), + tokensUsed: asNumber(row.tokensUsed), + createdAt: asString(row.createdAt), + })); + return { total: rows.length, byActionClass, byEventType, totalCostUsd: hasCost ? costTotal : null, recent }; +} + +function summarizePrOutcomes(records: PrOutcomeRecord[]): PrOutcomesSummary { + const byDecision: Record = { merged: 0, closed: 0 }; + const byReason: Record = {}; + for (const record of records) { + const decision = asString(record.decision); + if (decision === "merged" || decision === "closed") byDecision[decision] += 1; + const reason = asString(record.reason); + if (reason) byReason[reason] = (byReason[reason] ?? 0) + 1; + } + // readPrOutcomes yields most-recently-updated LAST (#7222), so slice-tail + reverse is newest-first, matching the + // ledgers feed. Projected to SAFE columns only. + const recent = records + .slice(-RECENT_EVENT_LIMIT) + .reverse() + .map((record) => ({ + repoFullName: asString(record.repoFullName), + prNumber: asNumber(record.prNumber), + decision: asString(record.decision) === "closed" ? ("closed" as const) : ("merged" as const), + reason: asString(record.reason), + closedAt: asString(record.closedAt), + })); + return { total: records.length, byDecision, byReason, recent }; +} + +export type AttemptLogApiDeps = { + loadAttemptLogModule: () => Promise; + loadEventLedgerModule: () => Promise; + loadPrOutcomeModule: () => Promise; + fileExists: (path: string) => boolean; +}; + +const defaultDeps: AttemptLogApiDeps = { + loadAttemptLogModule: () => import("../../packages/loopover-miner/lib/attempt-log.js") as Promise, + loadEventLedgerModule: () => + import("../../packages/loopover-miner/lib/event-ledger.js") as Promise, + loadPrOutcomeModule: () => import("../../packages/loopover-miner/lib/pr-outcome.js") as Promise, + fileExists: existsSync, +}; + +/** Request handler factored out of the Vite plugin shape so tests drive it directly (mirrors the sibling APIs). */ +export async function handleAttemptLogRequest( + method: string | undefined, + url: string | undefined, + deps: AttemptLogApiDeps = defaultDeps, +): Promise<{ status: number; body: string } | null> { + if (url !== "/api/attempt-log" || (method !== undefined && method !== "GET")) return null; + try { + const summary = emptyAttemptLogSummary(); + + const attemptLog = await deps.loadAttemptLogModule(); + if (deps.fileExists(attemptLog.resolveAttemptLogDbPath())) { + summary.attempts = summarizeAttempts(attemptLog.readAttemptLogEvents()); + } + // PR-outcomes live in the EVENT ledger (pr-outcome.js is a typed view over it), so they are gated on the event + // ledger's own DB file and reduced through a reader built from that ledger's `readEvents` export (#7656). + const eventLedger = await deps.loadEventLedgerModule(); + const prOutcome = await deps.loadPrOutcomeModule(); + if (deps.fileExists(eventLedger.resolveEventLedgerDbPath())) { + const reader = { readEvents: (filter?: unknown) => eventLedger.readEvents(filter) }; + summary.prOutcomes = summarizePrOutcomes([...prOutcome.readPrOutcomes(reader).values()]); + } + return { status: 200, body: JSON.stringify({ summary }) }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read the local attempt log"; + return { status: 500, body: JSON.stringify({ error: message }) }; + } +} + +/** Vite dev/preview middleware serving the local read-only attempt-log endpoint. */ +export function attemptLogApiPlugin(deps: AttemptLogApiDeps = defaultDeps): Plugin { + const attach = (middlewares: { + use: ( + fn: ( + req: { method?: string; url?: string }, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, + ) => void, + ) => void; + }) => { + middlewares.use((req, res, next) => { + void handleAttemptLogRequest(req.method, req.url, deps).then((handled) => { + if (!handled) return next(); + res.statusCode = handled.status; + res.setHeader("Content-Type", "application/json"); + res.end(handled.body); + }); + }); + }; + return { + name: "loopover-miner-ui:attempt-log-api", + configureServer(server) { + attach(server.middlewares); + }, + configurePreviewServer(server) { + attach(server.middlewares); + }, + }; +} diff --git a/apps/loopover-miner-ui/vite.config.ts b/apps/loopover-miner-ui/vite.config.ts index 9abb4b3398..8fcdcaff2d 100644 --- a/apps/loopover-miner-ui/vite.config.ts +++ b/apps/loopover-miner-ui/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { attemptApiPlugin } from "./vite-attempt-api"; +import { attemptLogApiPlugin } from "./vite-attempt-log-api"; import { authPlugin } from "./vite-auth"; import { chatApiPlugin } from "./vite-chat-api"; import { chatDiscoverAttemptActionsPlugin } from "./vite-chat-discover-attempt-actions"; @@ -33,6 +34,7 @@ export default defineConfig({ portfolioQueueApiPlugin(), portfolioQueueActionsApiPlugin(), ledgersApiPlugin(), + attemptLogApiPlugin(), governorApiPlugin(), rankedCandidatesApiPlugin(), discoverApiPlugin(),