; 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.