diff --git a/apps/gittensory-miner-ui/src/ledgers.test.tsx b/apps/gittensory-miner-ui/src/ledgers.test.tsx new file mode 100644 index 0000000000..de8605a739 --- /dev/null +++ b/apps/gittensory-miner-ui/src/ledgers.test.tsx @@ -0,0 +1,245 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + emptyLedgersSummary, + fetchLedgers, + LEDGERS_API_PATH, + type LedgersResult, + type LedgersSummary, +} from "./lib/ledgers"; +import { LedgersPage, LedgersView } from "./routes/ledgers"; +import { handleLedgersRequest, type LedgersApiDeps } from "../vite-ledgers-api"; + +const fixtureSummary: LedgersSummary = { + claims: { total: 3, byStatus: { active: 2, released: 1, expired: 0 } }, + events: { + total: 2, + byType: { attempt_started: 1, attempt_succeeded: 1 }, + recent: [ + { eventType: "attempt_succeeded", repoFullName: "acme/widgets", createdAt: "2026-07-10T06:05:00.000Z" }, + { eventType: "attempt_started", repoFullName: "acme/widgets", createdAt: "2026-07-10T06:00:00.000Z" }, + ], + }, + governor: { total: 2, byEventType: { rate_limit_deferred: 1, budget_deferred: 1 } }, +}; + +// Raw store rows carrying excluded raw columns (the free-text claim `note`, and event/governor payloads) the +// summary must NEVER republish. The API structurally omits these fields entirely, 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 on this fixture, while still proving the raw fields are dropped. +const rawClaimRows = [ + { + repoFullName: "private-org/watched-repo", + issueNumber: 12, + status: "active", + claimedAt: "t1", + note: "LEAK_CANARY_CLAIM_A", + }, + { + repoFullName: "private-org/watched-repo", + issueNumber: 13, + status: "released", + claimedAt: "t2", + note: "LEAK_CANARY_CLAIM_B", + }, + { repoFullName: "private-org/other", issueNumber: 7, status: "active", claimedAt: "t3", note: null }, +]; +const rawEventRows = [ + { + type: "attempt_started", + repoFullName: "private-org/watched-repo", + createdAt: "t1", + payload: { detail: "LEAK_CANARY_EVENT_A" }, + }, + { + type: "attempt_succeeded", + repoFullName: "private-org/watched-repo", + createdAt: "t2", + payload_json: '{"detail":"LEAK_CANARY_EVENT_B"}', + }, +]; +const rawGovernorRows = [ + { + eventType: "rate_limit_deferred", + repoFullName: "private-org/watched-repo", + ts: "t1", + payload: { detail: "LEAK_CANARY_GOV_A" }, + }, + { + eventType: "budget_deferred", + repoFullName: "private-org/watched-repo", + ts: "t2", + payload_json: '{"detail":"LEAK_CANARY_GOV_B"}', + }, +]; + +describe("emptyLedgersSummary (#4855)", () => { + it("summarizes empty ledgers to zeros", () => { + expect(emptyLedgersSummary()).toEqual({ + claims: { total: 0, byStatus: { active: 0, released: 0, expired: 0 } }, + events: { total: 0, byType: {}, recent: [] }, + governor: { total: 0, byEventType: {} }, + }); + }); +}); + +describe("LedgersView (#4855)", () => { + it("renders claim status counts, the governor type table, and the recent-events feed", () => { + render(); + expect(screen.getByText("Active", { selector: "dt" }).nextSibling?.textContent).toBe("2"); + expect(screen.getByText("Released", { selector: "dt" }).nextSibling?.textContent).toBe("1"); + expect(screen.getByText("rate_limit_deferred")).toBeTruthy(); + expect(screen.getByText("attempt_succeeded")).toBeTruthy(); + expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0); + }); + + it("renders the fresh-install empty state when every ledger is empty", () => { + render(); + expect(screen.getByText(/No ledger activity 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 the loading state before the first result arrives", () => { + render(); + expect(screen.getByText(/Loading local ledgers/i)).toBeTruthy(); + }); +}); + +describe("LedgersPage (#4855)", () => { + it("loads the summary through the injected loader and renders it", async () => { + const loadLedgers = async (): Promise => ({ ok: true, summary: fixtureSummary }); + render(); + expect(screen.getByRole("heading", { name: "Ledgers" })).toBeTruthy(); + await waitFor(() => expect(screen.getByText("Active", { selector: "dt" }).nextSibling?.textContent).toBe("2")); + }); +}); + +describe("fetchLedgers (#4855)", () => { + 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 fetchLedgers(async (input) => { + requested = String(input); + return jsonResponse(200, { summary: fixtureSummary }); + }); + expect(requested).toBe(LEDGERS_API_PATH); + expect(result).toEqual({ ok: true, summary: fixtureSummary }); + }); + + it("surfaces non-2xx, malformed payloads, and thrown fetches as typed errors", async () => { + expect(await fetchLedgers(async () => jsonResponse(500, {}))).toEqual({ + ok: false, + error: "local ledgers API responded 500", + }); + expect(await fetchLedgers(async () => jsonResponse(200, { summary: { claims: { total: 1 } } }))).toMatchObject({ + ok: false, + }); + expect( + await fetchLedgers(async () => { + throw new Error("connection refused"); + }), + ).toEqual({ ok: false, error: "connection refused" }); + }); +}); + +describe("handleLedgersRequest (#4855)", () => { + function deps(overrides: Partial = {}): LedgersApiDeps { + return { + loadClaimLedgerModule: async () => ({ + resolveClaimLedgerDbPath: () => "/home/miner/.config/gittensory-miner/claim-ledger.sqlite3", + listClaims: () => rawClaimRows, + }), + loadEventLedgerModule: async () => ({ + resolveEventLedgerDbPath: () => "/home/miner/.config/gittensory-miner/event-ledger.sqlite3", + readEvents: () => rawEventRows, + }), + loadGovernorLedgerModule: async () => ({ + resolveGovernorLedgerDbPath: () => "/home/miner/.config/gittensory-miner/governor-ledger.sqlite3", + readGovernorEvents: () => rawGovernorRows, + }), + fileExists: () => true, + ...overrides, + }; + } + + it("aggregates the three ledgers to counts and a safe recent-events feed", async () => { + const handled = await handleLedgersRequest("GET", "/api/ledgers", deps()); + expect(handled?.status).toBe(200); + const body = JSON.parse(handled?.body ?? "{}") as { summary: LedgersSummary }; + expect(body.summary.claims).toEqual({ total: 3, byStatus: { active: 2, released: 1, expired: 0 } }); + expect(body.summary.governor).toEqual({ total: 2, byEventType: { rate_limit_deferred: 1, budget_deferred: 1 } }); + expect(body.summary.events.total).toBe(2); + expect(body.summary.events.byType).toEqual({ attempt_started: 1, attempt_succeeded: 1 }); + expect(body.summary.events.recent[0]).toEqual({ + eventType: "attempt_succeeded", + repoFullName: "private-org/watched-repo", + createdAt: "t2", + }); + }); + + it("INVARIANT (canary): never republishes the claim note or any raw event/governor payload", async () => { + const handled = await handleLedgersRequest("GET", "/api/ledgers", deps()); + const body = handled?.body ?? ""; + // Repo names are fine (already shown locally by the CLI's own dashboards), but every excluded raw column — + // the free-text note and the event/governor payloads (whatever they hold) — must be structurally absent. + for (const forbidden of [ + "LEAK_CANARY_CLAIM_A", + "LEAK_CANARY_CLAIM_B", + "LEAK_CANARY_EVENT_A", + "LEAK_CANARY_EVENT_B", + "LEAK_CANARY_GOV_A", + "LEAK_CANARY_GOV_B", + "payload", + "note", + "detail", + ]) { + expect(body).not.toContain(forbidden); + } + }); + + it("serves an empty summary on a fresh install WITHOUT initializing any store", async () => { + let touched = false; + const handled = await handleLedgersRequest( + "GET", + "/api/ledgers", + deps({ + fileExists: () => false, + loadClaimLedgerModule: async () => ({ + resolveClaimLedgerDbPath: () => "/nowhere/claim-ledger.sqlite3", + listClaims: () => { + touched = true; + return rawClaimRows; + }, + }), + }), + ); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ summary: emptyLedgersSummary() }) }); + expect(touched).toBe(false); + }); + + it("falls through (null) for other paths and non-GET methods", async () => { + expect(await handleLedgersRequest("GET", "/api/portfolio-queue", deps())).toBeNull(); + expect(await handleLedgersRequest("POST", "/api/ledgers", deps())).toBeNull(); + }); + + it("surfaces a store read failure as a 500 with a safe message", async () => { + const handled = await handleLedgersRequest( + "GET", + "/api/ledgers", + deps({ + loadGovernorLedgerModule: async () => { + throw new Error("sqlite locked"); + }, + }), + ); + expect(handled).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) }); + }); +}); diff --git a/apps/gittensory-miner-ui/src/lib/ledgers.ts b/apps/gittensory-miner-ui/src/lib/ledgers.ts new file mode 100644 index 0000000000..25ff727859 --- /dev/null +++ b/apps/gittensory-miner-ui/src/lib/ledgers.ts @@ -0,0 +1,76 @@ +// Read-only client for the local ledgers API (#4855). The middleware aggregates the claim / event / governor +// ledgers server-side into status/type counts plus a small feed of SAFE columns — it never republishes raw +// payloads, the free-text claim note, or any secret-shaped value (the same invariant 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. + +export const LEDGERS_API_PATH = "/api/ledgers"; + +export const CLAIM_STATUSES = ["active", "released", "expired"] as const; +export type ClaimStatus = (typeof CLAIM_STATUSES)[number]; +export type ClaimStatusCounts = Record; + +export type ClaimsSummary = { total: number; byStatus: ClaimStatusCounts }; +export type EventFeedEntry = { eventType: string; repoFullName: string | null; createdAt: string | null }; +export type EventsSummary = { total: number; byType: Record; recent: EventFeedEntry[] }; +export type GovernorSummary = { total: number; byEventType: Record }; +export type LedgersSummary = { claims: ClaimsSummary; events: EventsSummary; governor: GovernorSummary }; + +export type LedgersResult = { ok: true; summary: LedgersSummary } | { ok: false; error: string }; + +export const emptyLedgersSummary = (): LedgersSummary => ({ + claims: { total: 0, byStatus: { active: 0, released: 0, expired: 0 } }, + events: { total: 0, byType: {}, recent: [] }, + governor: { total: 0, byEventType: {} }, +}); + +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 isClaimStatusCounts(value: unknown): value is ClaimStatusCounts { + return isRecord(value) && CLAIM_STATUSES.every((status) => typeof value[status] === "number"); +} + +function isEventFeedEntry(value: unknown): value is EventFeedEntry { + if (!isRecord(value)) return false; + const okName = value.repoFullName === null || typeof value.repoFullName === "string"; + const okAt = value.createdAt === null || typeof value.createdAt === "string"; + return typeof value.eventType === "string" && okName && okAt; +} + +function isLedgersSummary(value: unknown): value is LedgersSummary { + if (!isRecord(value)) return false; + const { claims, events, governor } = value as Record; + if (!isRecord(claims) || typeof claims.total !== "number" || !isClaimStatusCounts(claims.byStatus)) return false; + if ( + !isRecord(events) || + typeof events.total !== "number" || + !isCountMap(events.byType) || + !Array.isArray(events.recent) || + !events.recent.every(isEventFeedEntry) + ) { + return false; + } + if (!isRecord(governor) || typeof governor.total !== "number" || !isCountMap(governor.byEventType)) return false; + return true; +} + +/** Fetch the local ledgers summary; failures surface as a typed error result the view renders, never a crash. */ +export async function fetchLedgers(fetchImpl: typeof fetch = fetch): Promise { + try { + const response = await fetchImpl(LEDGERS_API_PATH); + if (!response.ok) return { ok: false, error: `local ledgers API responded ${response.status}` }; + const payload: unknown = await response.json(); + const summary = (payload as { summary?: unknown }).summary; + if (!isLedgersSummary(summary)) + return { ok: false, error: "local ledgers 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 ledgers API" }; + } +} diff --git a/apps/gittensory-miner-ui/src/routeTree.gen.ts b/apps/gittensory-miner-ui/src/routeTree.gen.ts index 3afffb3aec..68fee4899d 100644 --- a/apps/gittensory-miner-ui/src/routeTree.gen.ts +++ b/apps/gittensory-miner-ui/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as RunHistoryRouteImport } from './routes/run-history' import { Route as PortfolioRouteImport } from './routes/portfolio' +import { Route as LedgersRouteImport } from './routes/ledgers' import { Route as IndexRouteImport } from './routes/index' const RunHistoryRoute = RunHistoryRouteImport.update({ @@ -23,6 +24,11 @@ const PortfolioRoute = PortfolioRouteImport.update({ path: '/portfolio', getParentRoute: () => rootRouteImport, } as any) +const LedgersRoute = LedgersRouteImport.update({ + id: '/ledgers', + path: '/ledgers', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -31,30 +37,34 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute '/run-history': typeof RunHistoryRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute '/run-history': typeof RunHistoryRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/ledgers': typeof LedgersRoute '/portfolio': typeof PortfolioRoute '/run-history': typeof RunHistoryRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/portfolio' | '/run-history' + fullPaths: '/' | '/ledgers' | '/portfolio' | '/run-history' fileRoutesByTo: FileRoutesByTo - to: '/' | '/portfolio' | '/run-history' - id: '__root__' | '/' | '/portfolio' | '/run-history' + to: '/' | '/ledgers' | '/portfolio' | '/run-history' + id: '__root__' | '/' | '/ledgers' | '/portfolio' | '/run-history' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + LedgersRoute: typeof LedgersRoute PortfolioRoute: typeof PortfolioRoute RunHistoryRoute: typeof RunHistoryRoute } @@ -75,6 +85,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PortfolioRouteImport parentRoute: typeof rootRouteImport } + '/ledgers': { + id: '/ledgers' + path: '/ledgers' + fullPath: '/ledgers' + preLoaderRoute: typeof LedgersRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -87,6 +104,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + LedgersRoute: LedgersRoute, PortfolioRoute: PortfolioRoute, RunHistoryRoute: RunHistoryRoute, } diff --git a/apps/gittensory-miner-ui/src/routes/__root.tsx b/apps/gittensory-miner-ui/src/routes/__root.tsx index 852196ef67..a65b20da8d 100644 --- a/apps/gittensory-miner-ui/src/routes/__root.tsx +++ b/apps/gittensory-miner-ui/src/routes/__root.tsx @@ -24,6 +24,9 @@ function RootLayout() { Portfolio + + Ledgers + diff --git a/apps/gittensory-miner-ui/src/routes/ledgers.tsx b/apps/gittensory-miner-ui/src/routes/ledgers.tsx new file mode 100644 index 0000000000..53e1ea855d --- /dev/null +++ b/apps/gittensory-miner-ui/src/routes/ledgers.tsx @@ -0,0 +1,162 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import { Card, CardContent, CardHeader } from "@jsonbored/gittensory-ui-kit/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@jsonbored/gittensory-ui-kit/components/table"; + +import { CLAIM_STATUSES, fetchLedgers, type ClaimStatus, type LedgersResult } from "../lib/ledgers"; + +export const Route = createFileRoute("/ledgers")({ + component: LedgersPage, +}); + +// Read-only views over the miner's local claim / event / governor ledgers (#4855). All three are aggregated +// server-side (see vite-ledgers-api.ts) to status/type counts plus a small feed of SAFE columns — raw payloads +// and the free-text claim note never reach this component. Same 4-state pattern as the portfolio/run-history +// views (loading / error / fresh-install empty / populated). + +const CLAIM_STATUS_LABELS: Record = { + active: "Active", + released: "Released", + expired: "Expired", +}; + +const CLAIM_STATUS_TONE: Record = { + active: "text-[var(--success)]", + released: "text-muted-foreground", + expired: "text-[var(--warning)]", +}; + +function CountTable({ counts, keyLabel }: { counts: Record; keyLabel: string }) { + const entries = Object.entries(counts).sort(([, a], [, b]) => b - a); + return ( + + + + {keyLabel} + Count + + + + {entries.map(([type, count]) => ( + + {type} + {count} + + ))} + +
+ ); +} + +export function LedgersView({ result }: { result: LedgersResult | null }) { + if (result === null) { + return

Loading local ledgers…

; + } + if (!result.ok) { + return ( +

+ Could not read the local ledgers: {result.error} +

+ ); + } + const { claims, events, governor } = result.summary; + if (claims.total === 0 && events.total === 0 && governor.total === 0) { + return ( +

+ No ledger activity yet — claims, events, and governor entries appear here once the miner starts working. +

+ ); + } + return ( +
+
+

Claims ({claims.total})

+
+ {CLAIM_STATUSES.map((status) => ( + + +
+ {CLAIM_STATUS_LABELS[status]} +
+
+ {claims.byStatus[status]} +
+
+
+ ))} +
+
+ +
+

Governor events ({governor.total})

+ {governor.total === 0 ? ( +

No governor events recorded.

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

Recent events ({events.total})

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

No event-ledger entries recorded.

+ ) : ( + + + + Event type + Repository + Recorded + + + + {events.recent.map((entry, index) => ( + + {entry.eventType} + {entry.repoFullName ?? "—"} + {entry.createdAt ?? "—"} + + ))} + +
+ )} +
+
+ ); +} + +export function LedgersPage({ loadLedgers = fetchLedgers }: { loadLedgers?: () => Promise }) { + const [result, setResult] = useState(null); + + useEffect(() => { + let cancelled = false; + void loadLedgers().then((loaded) => { + if (!cancelled) setResult(loaded); + }); + return () => { + cancelled = true; + }; + }, [loadLedgers]); + + return ( + + +

Ledgers

+

+ Local, read-only summary of the miner's claim, event, and governor ledgers. +

+
+ + + +
+ ); +} diff --git a/apps/gittensory-miner-ui/vite-ledgers-api.ts b/apps/gittensory-miner-ui/vite-ledgers-api.ts new file mode 100644 index 0000000000..52c5ee166f --- /dev/null +++ b/apps/gittensory-miner-ui/vite-ledgers-api.ts @@ -0,0 +1,161 @@ +import { existsSync } from "node:fs"; +import type { Plugin } from "vite"; + +// Local read-only ledgers API (#4855) — sibling of `vite-portfolio-queue-api.ts` / `vite-run-state-api.ts`, same +// shape and same reason: the dashboard is a browser app while the claim / event / governor ledgers are +// `node:sqlite` files on disk, so the dev server bridges the two by calling into the EXISTING read exports of +// `packages/gittensory-miner/lib/{claim,event,governor}-ledger.js`. +// +// SAFETY: every ledger is aggregated SERVER-SIDE to status/type COUNTS plus a small feed of explicitly-projected +// SAFE columns. Raw `payload_json` (governor/event) and the free-text claim `note` NEVER cross the wire — the +// same "no secret-shaped value, no excluded raw column" invariant the read-only MCP tools enforce (#5199). +// +// Same read-only fresh-install rule as the sibling endpoints: the default `list*`/`read*` exports lazily +// initialize their store, which would CREATE the SQLite file — so each ledger'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 CLAIM_STATUSES = ["active", "released", "expired"] as const; +type ClaimStatus = (typeof CLAIM_STATUSES)[number]; + +type ClaimRow = { repoFullName?: unknown; issueNumber?: unknown; status?: unknown; claimedAt?: unknown }; +type EventRow = { type?: unknown; repoFullName?: unknown; createdAt?: unknown }; +type GovernorRow = { eventType?: unknown; repoFullName?: unknown; ts?: unknown }; + +type ClaimLedgerModule = { resolveClaimLedgerDbPath: () => string; listClaims: (filter?: unknown) => ClaimRow[] }; +type EventLedgerModule = { resolveEventLedgerDbPath: () => string; readEvents: (filter?: unknown) => EventRow[] }; +type GovernorLedgerModule = { + resolveGovernorLedgerDbPath: () => string; + readGovernorEvents: (filter?: unknown) => GovernorRow[]; +}; + +export type ClaimsSummary = { total: number; byStatus: Record }; +export type EventFeedEntry = { eventType: string; repoFullName: string | null; createdAt: string | null }; +export type EventsSummary = { total: number; byType: Record; recent: EventFeedEntry[] }; +export type GovernorSummary = { total: number; byEventType: Record }; +export type LedgersSummary = { claims: ClaimsSummary; events: EventsSummary; governor: GovernorSummary }; + +export function emptyLedgersSummary(): LedgersSummary { + return { + claims: { total: 0, byStatus: { active: 0, released: 0, expired: 0 } }, + events: { total: 0, byType: {}, recent: [] }, + governor: { total: 0, byEventType: {} }, + }; +} + +const asString = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null); + +function summarizeClaims(rows: ClaimRow[]): ClaimsSummary { + const byStatus: Record = { active: 0, released: 0, expired: 0 }; + for (const row of rows) { + if (typeof row.status === "string" && (CLAIM_STATUSES as readonly string[]).includes(row.status)) { + byStatus[row.status as ClaimStatus] += 1; + } + } + return { total: rows.length, byStatus }; +} + +function summarizeEvents(rows: EventRow[]): EventsSummary { + const byType: Record = {}; + for (const row of rows) { + const type = asString(row.type); + if (type) byType[type] = (byType[type] ?? 0) + 1; + } + // Newest-first, capped — and projected to SAFE columns only (never the raw payload). + const recent = rows + .slice(-RECENT_EVENT_LIMIT) + .reverse() + .map((row) => ({ + eventType: asString(row.type) ?? "unknown", + repoFullName: asString(row.repoFullName), + createdAt: asString(row.createdAt), + })); + return { total: rows.length, byType, recent }; +} + +function summarizeGovernor(rows: GovernorRow[]): GovernorSummary { + const byEventType: Record = {}; + for (const row of rows) { + const type = asString(row.eventType); + if (type) byEventType[type] = (byEventType[type] ?? 0) + 1; + } + return { total: rows.length, byEventType }; +} + +export type LedgersApiDeps = { + loadClaimLedgerModule: () => Promise; + loadEventLedgerModule: () => Promise; + loadGovernorLedgerModule: () => Promise; + fileExists: (path: string) => boolean; +}; + +const defaultDeps: LedgersApiDeps = { + loadClaimLedgerModule: () => + import("../../packages/gittensory-miner/lib/claim-ledger.js") as Promise, + loadEventLedgerModule: () => + import("../../packages/gittensory-miner/lib/event-ledger.js") as Promise, + loadGovernorLedgerModule: () => + import("../../packages/gittensory-miner/lib/governor-ledger.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 handleLedgersRequest( + method: string | undefined, + url: string | undefined, + deps: LedgersApiDeps = defaultDeps, +): Promise<{ status: number; body: string } | null> { + if (url !== "/api/ledgers" || (method !== undefined && method !== "GET")) return null; + try { + const summary = emptyLedgersSummary(); + + const claims = await deps.loadClaimLedgerModule(); + if (deps.fileExists(claims.resolveClaimLedgerDbPath())) { + summary.claims = summarizeClaims(claims.listClaims()); + } + const events = await deps.loadEventLedgerModule(); + if (deps.fileExists(events.resolveEventLedgerDbPath())) { + summary.events = summarizeEvents(events.readEvents()); + } + const governor = await deps.loadGovernorLedgerModule(); + if (deps.fileExists(governor.resolveGovernorLedgerDbPath())) { + summary.governor = summarizeGovernor(governor.readGovernorEvents()); + } + return { status: 200, body: JSON.stringify({ summary }) }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read the local ledgers"; + return { status: 500, body: JSON.stringify({ error: message }) }; + } +} + +/** Vite dev/preview middleware serving the local read-only ledgers endpoint. */ +export function ledgersApiPlugin(deps: LedgersApiDeps = 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 handleLedgersRequest(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: "gittensory-miner-ui:ledgers-api", + configureServer(server) { + attach(server.middlewares); + }, + configurePreviewServer(server) { + attach(server.middlewares); + }, + }; +} diff --git a/apps/gittensory-miner-ui/vite.config.ts b/apps/gittensory-miner-ui/vite.config.ts index 50a5f69fa6..e4c188f9cf 100644 --- a/apps/gittensory-miner-ui/vite.config.ts +++ b/apps/gittensory-miner-ui/vite.config.ts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +import { ledgersApiPlugin } from "./vite-ledgers-api"; import { portfolioQueueApiPlugin } from "./vite-portfolio-queue-api"; import { runStateApiPlugin } from "./vite-run-state-api"; @@ -15,6 +16,7 @@ export default defineConfig({ tsconfigPaths(), runStateApiPlugin(), portfolioQueueApiPlugin(), + ledgersApiPlugin(), ], server: { // Offset from gittensory-ui (5173) so both apps can run side-by-side locally.