diff --git a/apps/gittensory-miner-ui/src/governor.test.tsx b/apps/gittensory-miner-ui/src/governor.test.tsx new file mode 100644 index 000000000..4607e9864 --- /dev/null +++ b/apps/gittensory-miner-ui/src/governor.test.tsx @@ -0,0 +1,437 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + defaultGovernorPauseState, + fetchGovernorPauseState, + GOVERNOR_PAUSE_API_PATH, + GOVERNOR_PAUSE_STATE_API_PATH, + GOVERNOR_RESUME_API_PATH, + pauseGovernor, + resumeGovernor, + type GovernorPauseState, + type GovernorPauseStateResult, +} from "./lib/governor"; +import { GovernorControlSection } from "./routes/ledgers"; +import { + governorApiPlugin, + handleGovernorRequest, + matchGovernorRoute, + type GovernorApiDeps, +} from "../vite-governor-api"; + +const pausedState: GovernorPauseState = { + paused: true, + reason: "investigating a bad PR", + pausedAt: "2026-07-13T12:00:00.000Z", +}; + +describe("defaultGovernorPauseState (#4857)", () => { + it("is the not-paused state with no reason/timestamp", () => { + expect(defaultGovernorPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); + }); +}); + +describe("GovernorControlSection (#4857)", () => { + it("renders the loading state before the first result arrives", () => { + render( + undefined} onResume={() => undefined} />, + ); + expect(screen.getByText(/Loading governor state/i)).toBeTruthy(); + }); + + it("renders an error message when the local API is unreachable", () => { + render( + undefined} + onResume={() => undefined} + />, + ); + expect(screen.getByRole("alert").textContent).toContain("connection refused"); + }); + + it("shows a Pause button and calls onPause when not currently paused", () => { + const onPause = vi.fn(); + render( + undefined} + />, + ); + expect(screen.getByText("Not paused")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Pause governor" })); + expect(onPause).toHaveBeenCalledTimes(1); + }); + + it("shows the pause reason/timestamp and a Resume button and calls onResume when currently paused", () => { + const onResume = vi.fn(); + render( + undefined} + onResume={onResume} + />, + ); + expect(screen.getByText(/Paused since 2026-07-13T12:00:00.000Z \(investigating a bad PR\)/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Resume governor" })); + expect(onResume).toHaveBeenCalledTimes(1); + }); + + it("disables the action button while an action is pending", () => { + render( + undefined} + onResume={() => undefined} + />, + ); + expect((screen.getByRole("button", { name: "Pause governor" }) as HTMLButtonElement).disabled).toBe(true); + }); +}); + +describe("fetchGovernorPauseState / pauseGovernor / resumeGovernor (#4857)", () => { + const jsonResponse = (status: number, payload: unknown) => + ({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response; + + it("fetchGovernorPauseState returns a typed pause state from a well-formed payload, requesting the local API path", async () => { + let requested: string | undefined; + const result = await fetchGovernorPauseState(async (input) => { + requested = String(input); + return jsonResponse(200, { pauseState: pausedState }); + }); + expect(requested).toBe(GOVERNOR_PAUSE_STATE_API_PATH); + expect(result).toEqual({ ok: true, pauseState: pausedState }); + }); + + it("fetchGovernorPauseState surfaces non-2xx, malformed payloads, and thrown fetches as typed errors", async () => { + expect(await fetchGovernorPauseState(async () => jsonResponse(500, {}))).toEqual({ + ok: false, + error: "local governor pause-state API responded 500", + }); + expect( + await fetchGovernorPauseState(async () => jsonResponse(200, { pauseState: { paused: "yes" } })), + ).toMatchObject({ + ok: false, + }); + expect( + await fetchGovernorPauseState(async () => { + throw new Error("connection refused"); + }), + ).toEqual({ ok: false, error: "connection refused" }); + }); + + it("pauseGovernor POSTs a reason body to the pause path and returns the resulting pause state", async () => { + let requested: { input: string; init: RequestInit | undefined } | undefined; + const result = await pauseGovernor("bad PR", async (input, init) => { + requested = { input: String(input), init }; + return jsonResponse(200, { pauseState: pausedState }); + }); + expect(requested?.input).toBe(GOVERNOR_PAUSE_API_PATH); + expect(requested?.init?.method).toBe("POST"); + expect(JSON.parse(String(requested?.init?.body))).toEqual({ reason: "bad PR" }); + expect(result).toEqual({ ok: true, pauseState: pausedState }); + }); + + it("pauseGovernor omits reason from the body when not given", async () => { + let requested: { init: RequestInit | undefined } | undefined; + await pauseGovernor(undefined, async (input, init) => { + requested = { init }; + return jsonResponse(200, { pauseState: pausedState }); + }); + expect(JSON.parse(String(requested?.init?.body))).toEqual({}); + }); + + it("resumeGovernor POSTs to the resume path with an empty body and returns the resulting pause state", async () => { + let requested: { input: string; init: RequestInit | undefined } | undefined; + const result = await resumeGovernor(async (input, init) => { + requested = { input: String(input), init }; + return jsonResponse(200, { pauseState: defaultGovernorPauseState() }); + }); + expect(requested?.input).toBe(GOVERNOR_RESUME_API_PATH); + expect(requested?.init?.method).toBe("POST"); + expect(JSON.parse(String(requested?.init?.body))).toEqual({}); + expect(result).toEqual({ ok: true, pauseState: defaultGovernorPauseState() }); + }); + + it("pauseGovernor/resumeGovernor surface a thrown fetch as a typed error", async () => { + const failing: GovernorPauseStateResult = { ok: false, error: "connection refused" }; + expect( + await pauseGovernor("x", async () => { + throw new Error("connection refused"); + }), + ).toEqual(failing); + expect( + await resumeGovernor(async () => { + throw new Error("connection refused"); + }), + ).toEqual(failing); + }); +}); + +describe("matchGovernorRoute (#4857)", () => { + it("matches GET (or method-less) requests to /api/governor/pause-state", () => { + expect(matchGovernorRoute("GET", "/api/governor/pause-state")).toBe("pause-state-get"); + expect(matchGovernorRoute(undefined, "/api/governor/pause-state")).toBe("pause-state-get"); + }); + + it("matches POST /api/governor/pause and /api/governor/resume", () => { + expect(matchGovernorRoute("POST", "/api/governor/pause")).toBe("pause-post"); + expect(matchGovernorRoute("POST", "/api/governor/resume")).toBe("resume-post"); + }); + + it("matches nothing for any other method/path combination", () => { + expect(matchGovernorRoute("POST", "/api/governor/pause-state")).toBeNull(); + expect(matchGovernorRoute("GET", "/api/governor/pause")).toBeNull(); + expect(matchGovernorRoute("GET", "/api/portfolio-queue")).toBeNull(); + }); +}); + +describe("handleGovernorRequest (#4857)", () => { + function deps(overrides: Partial = {}): GovernorApiDeps { + return { + loadGovernorStateModule: async () => ({ + resolveGovernorStateDbPath: () => "/home/miner/.config/gittensory-miner/governor-state.sqlite3", + loadPauseState: () => pausedState, + savePauseState: (input) => ({ + paused: input.paused, + reason: input.paused ? (input.reason ?? null) : null, + pausedAt: input.paused ? "2026-07-13T12:30:00.000Z" : null, + }), + }), + fileExists: () => true, + ...overrides, + }; + } + + it("falls through (null) for a request that matches none of the three governor routes", async () => { + expect(await handleGovernorRequest("GET", "/api/portfolio-queue", "", deps())).toBeNull(); + expect(await handleGovernorRequest("POST", "/api/governor/pause-state", "", deps())).toBeNull(); + }); + + it("GET pause-state serves the real store's pause state", async () => { + const handled = await handleGovernorRequest("GET", "/api/governor/pause-state", "", deps()); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ pauseState: pausedState }) }); + }); + + it("GET pause-state serves the not-paused default on a fresh install WITHOUT calling loadPauseState", async () => { + // Mirrors the sibling read-only endpoints' fresh-install test shape: the module import itself (needed to + // read resolveGovernorStateDbPath for the fileExists check) is expected, but the STORE-touching call + // (loadPauseState) must never fire once fileExists says there is nothing to read yet. + let loaded = false; + const handled = await handleGovernorRequest( + "GET", + "/api/governor/pause-state", + "", + deps({ + fileExists: () => false, + loadGovernorStateModule: async () => ({ + resolveGovernorStateDbPath: () => "/nowhere/governor-state.sqlite3", + loadPauseState: () => { + loaded = true; + return pausedState; + }, + savePauseState: () => defaultGovernorPauseState(), + }), + }), + ); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ pauseState: defaultGovernorPauseState() }) }); + expect(loaded).toBe(false); + }); + + it("POST pause parses an optional reason from the request body and returns the saved pause state", async () => { + const handled = await handleGovernorRequest( + "POST", + "/api/governor/pause", + JSON.stringify({ reason: "bad PR" }), + deps(), + ); + expect(handled).toEqual({ + status: 200, + body: JSON.stringify({ pauseState: { paused: true, reason: "bad PR", pausedAt: "2026-07-13T12:30:00.000Z" } }), + }); + }); + + it("POST pause tolerates an empty or malformed body (no reason)", async () => { + const empty = await handleGovernorRequest("POST", "/api/governor/pause", "", deps()); + expect(empty).toEqual({ + status: 200, + body: JSON.stringify({ pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" } }), + }); + const malformed = await handleGovernorRequest("POST", "/api/governor/pause", "{not json", deps()); + expect(malformed).toEqual({ + status: 200, + body: JSON.stringify({ pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" } }), + }); + const nonStringReason = await handleGovernorRequest( + "POST", + "/api/governor/pause", + JSON.stringify({ reason: 42 }), + deps(), + ); + expect(nonStringReason).toEqual({ + status: 200, + body: JSON.stringify({ pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" } }), + }); + }); + + it("POST resume saves the not-paused state, ignoring any body", async () => { + const handled = await handleGovernorRequest("POST", "/api/governor/resume", "", deps()); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ pauseState: defaultGovernorPauseState() }) }); + }); + + it("surfaces a store failure as a 500 with a safe message, for both read and write routes", async () => { + const brokenDeps = deps({ + loadGovernorStateModule: async () => { + throw new Error("sqlite locked"); + }, + }); + expect(await handleGovernorRequest("GET", "/api/governor/pause-state", "", brokenDeps)).toEqual({ + status: 500, + body: JSON.stringify({ error: "sqlite locked" }), + }); + expect(await handleGovernorRequest("POST", "/api/governor/pause", "", brokenDeps)).toEqual({ + status: 500, + body: JSON.stringify({ error: "sqlite locked" }), + }); + }); +}); + +type FakeReq = { method?: string; url?: string } & NodeJS.ReadableStream; + +/** A minimal Node-readable-stream double: `.on("data"/"end", ...)` registration works like a real stream, with + * the body (if any) emitted on a microtask so `readRequestBody`'s listeners are attached first, exactly like a + * real Node stream defers emission past the current synchronous tick. */ +function fakeRequest(method: string | undefined, url: string | undefined, body = ""): FakeReq { + const listeners: Record void>> = {}; + const req = { + method, + url, + on(event: string, cb: (...args: unknown[]) => void) { + (listeners[event] ??= []).push(cb); + return req; + }, + }; + queueMicrotask(() => { + if (body) for (const cb of listeners.data ?? []) cb(Buffer.from(body)); + for (const cb of listeners.end ?? []) cb(); + }); + return req as unknown as FakeReq; +} + +type CapturedRequestHandler = ( + req: FakeReq, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, +) => void; + +function captureMiddleware(deps?: Partial): CapturedRequestHandler { + let captured: CapturedRequestHandler | undefined; + const plugin = governorApiPlugin( + deps + ? { + loadGovernorStateModule: async () => ({ + resolveGovernorStateDbPath: () => "/home/miner/.config/gittensory-miner/governor-state.sqlite3", + loadPauseState: () => defaultGovernorPauseState(), + savePauseState: (input) => ({ paused: input.paused, reason: input.reason ?? null, pausedAt: null }), + }), + fileExists: () => true, + ...deps, + } + : undefined, + ); + const server = { middlewares: { use: (fn: CapturedRequestHandler) => (captured = fn) } }; + // @ts-expect-error -- the test double only implements the subset of Vite's ViteDevServer this plugin reads. + plugin.configureServer(server); + if (!captured) throw new Error("governorApiPlugin did not register a middleware"); + return captured; +} + +function fakeResponse() { + const headers: Record = {}; + let statusCode = 200; + let ended: string | undefined; + return { + res: { + get statusCode() { + return statusCode; + }, + set statusCode(value: number) { + statusCode = value; + }, + setHeader: (k: string, v: string) => { + headers[k] = v; + }, + end: (body: string) => { + ended = body; + }, + }, + headers, + getEnded: () => ended, + getStatus: () => statusCode, + }; +} + +describe("governorApiPlugin (#4857)", () => { + it("falls through to next() for a request that matches none of the three governor routes, never reading its body", async () => { + const middleware = captureMiddleware(); + const { res } = fakeResponse(); + let calledNext = false; + // A body on a non-matching request would hang readRequestBody's Promise if it were (wrongly) read, since + // this fake request only ever emits once; calling next() synchronously here proves the body was never touched. + middleware(fakeRequest("GET", "/api/portfolio-queue"), res, () => { + calledNext = true; + }); + expect(calledNext).toBe(true); + }); + + it("serves GET /api/governor/pause-state from the real (injected) store", async () => { + const middleware = captureMiddleware({ + loadGovernorStateModule: async () => ({ + resolveGovernorStateDbPath: () => "/home/miner/.config/gittensory-miner/governor-state.sqlite3", + loadPauseState: () => pausedState, + savePauseState: () => defaultGovernorPauseState(), + }), + }); + const { res, getEnded, getStatus } = fakeResponse(); + middleware(fakeRequest("GET", "/api/governor/pause-state"), res, () => undefined); + await vi.waitFor(() => expect(getEnded()).toBeDefined()); + expect(getStatus()).toBe(200); + expect(JSON.parse(getEnded() ?? "{}")).toEqual({ pauseState: pausedState }); + }); + + it("reads a POST body and pauses via the real (injected) store", async () => { + const middleware = captureMiddleware({ + loadGovernorStateModule: async () => ({ + resolveGovernorStateDbPath: () => "/home/miner/.config/gittensory-miner/governor-state.sqlite3", + loadPauseState: () => defaultGovernorPauseState(), + savePauseState: (input) => ({ + paused: input.paused, + reason: input.reason ?? null, + pausedAt: input.paused ? "2026-07-13T12:30:00.000Z" : null, + }), + }), + }); + const { res, getEnded, getStatus } = fakeResponse(); + middleware(fakeRequest("POST", "/api/governor/pause", JSON.stringify({ reason: "bad PR" })), res, () => undefined); + await vi.waitFor(() => expect(getEnded()).toBeDefined()); + expect(getStatus()).toBe(200); + expect(JSON.parse(getEnded() ?? "{}")).toEqual({ + pauseState: { paused: true, reason: "bad PR", pausedAt: "2026-07-13T12:30:00.000Z" }, + }); + }); + + it("also attaches via configurePreviewServer for `vite preview`", () => { + let captured: CapturedRequestHandler | undefined; + const plugin = governorApiPlugin(); + const server = { middlewares: { use: (fn: CapturedRequestHandler) => (captured = fn) } }; + // @ts-expect-error -- same partial test double as configureServer above. + plugin.configurePreviewServer(server); + expect(captured).toBeTypeOf("function"); + }); +}); diff --git a/apps/gittensory-miner-ui/src/ledgers.test.tsx b/apps/gittensory-miner-ui/src/ledgers.test.tsx index de8605a73..0ead7172f 100644 --- a/apps/gittensory-miner-ui/src/ledgers.test.tsx +++ b/apps/gittensory-miner-ui/src/ledgers.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { emptyLedgersSummary, @@ -8,6 +8,7 @@ import { type LedgersResult, type LedgersSummary, } from "./lib/ledgers"; +import { defaultGovernorPauseState, type GovernorPauseState, type GovernorPauseStateResult } from "./lib/governor"; import { LedgersPage, LedgersView } from "./routes/ledgers"; import { handleLedgersRequest, type LedgersApiDeps } from "../vite-ledgers-api"; @@ -112,12 +113,105 @@ describe("LedgersView (#4855)", () => { }); describe("LedgersPage (#4855)", () => { + const loadGovernorPauseStateDefault = async (): Promise => ({ + ok: true, + pauseState: defaultGovernorPauseState(), + }); + it("loads the summary through the injected loader and renders it", async () => { const loadLedgers = async (): Promise => ({ ok: true, summary: fixtureSummary }); - render(); + render(); expect(screen.getByRole("heading", { name: "Ledgers" })).toBeTruthy(); await waitFor(() => expect(screen.getByText("Active", { selector: "dt" }).nextSibling?.textContent).toBe("2")); }); + + describe("governor control (#4857)", () => { + const loadLedgersEmpty = async (): Promise => ({ ok: true, summary: emptyLedgersSummary() }); + + it("loads the governor pause state through the injected loader on mount", async () => { + const loadGovernorPauseState = async (): Promise => ({ + ok: true, + pauseState: { paused: true, reason: "bad PR", pausedAt: "2026-07-13T12:00:00.000Z" }, + }); + render(); + await waitFor(() => expect(screen.getByText(/Paused since 2026-07-13T12:00:00.000Z \(bad PR\)/)).toBeTruthy()); + }); + + it("clicking Pause governor calls the injected pause action and updates the displayed state from its result", async () => { + const pausedResult: GovernorPauseStateResult = { + ok: true, + pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" }, + }; + const pauseGovernorAction = vi.fn(async (): Promise => pausedResult); + render( + , + ); + await waitFor(() => expect(screen.getByText("Not paused")).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: "Pause governor" })); + expect(pauseGovernorAction).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy()); + }); + + it("clicking Resume governor calls the injected resume action and updates the displayed state from its result", async () => { + const initiallyPaused: GovernorPauseState = { paused: true, reason: null, pausedAt: "2026-07-13T12:00:00.000Z" }; + const resumeGovernorAction = vi.fn(async (): Promise => ({ + ok: true, + pauseState: defaultGovernorPauseState(), + })); + render( + ({ ok: true, pauseState: initiallyPaused })} + resumeGovernorAction={resumeGovernorAction} + />, + ); + await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: "Resume governor" })); + expect(resumeGovernorAction).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText("Not paused")).toBeTruthy()); + }); + + it("disables the action button while the pause action is in flight, and re-enables it once it resolves", async () => { + let resolveAction: (value: GovernorPauseStateResult) => void = () => undefined; + const pauseGovernorAction = vi.fn( + () => + new Promise((resolve) => { + resolveAction = resolve; + }), + ); + render( + , + ); + await waitFor(() => expect(screen.getByRole("button", { name: "Pause governor" })).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: "Pause governor" })); + await waitFor(() => + expect((screen.getByRole("button", { name: "Pause governor" }) as HTMLButtonElement).disabled).toBe(true), + ); + resolveAction({ ok: true, pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" } }); + await waitFor(() => + expect((screen.getByRole("button", { name: "Resume governor" }) as HTMLButtonElement).disabled).toBe(false), + ); + }); + + it("renders an error message when the pause-state load fails, without breaking the ledgers section", async () => { + render( + ({ ok: false, error: "connection refused" })} + />, + ); + await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("connection refused")); + expect(screen.getByText(/No ledger activity yet/i)).toBeTruthy(); + }); + }); }); describe("fetchLedgers (#4855)", () => { diff --git a/apps/gittensory-miner-ui/src/lib/governor.ts b/apps/gittensory-miner-ui/src/lib/governor.ts new file mode 100644 index 000000000..ce5aa5ab7 --- /dev/null +++ b/apps/gittensory-miner-ui/src/lib/governor.ts @@ -0,0 +1,80 @@ +// Client for the local governor pause-state read + pause/resume write API (#4857, the governor half of "Add +// real actions to the miner-ui"). Mirrors portfolio-queue.ts's shape (typed result union, no throw on a bad +// response, a guard narrowing the parsed JSON payload) but adds two WRITE actions, the miner-ui's first — safe +// only because vite-auth.ts (#4858) now authenticates every /api/* request, including these. + +export const GOVERNOR_PAUSE_STATE_API_PATH = "/api/governor/pause-state"; +export const GOVERNOR_PAUSE_API_PATH = "/api/governor/pause"; +export const GOVERNOR_RESUME_API_PATH = "/api/governor/resume"; + +export type GovernorPauseState = { paused: boolean; reason: string | null; pausedAt: string | null }; + +export type GovernorPauseStateResult = { ok: true; pauseState: GovernorPauseState } | { ok: false; error: string }; + +function isGovernorPauseState(value: unknown): value is GovernorPauseState { + if (typeof value !== "object" || value === null) return false; + const state = value as Record; + return ( + typeof state.paused === "boolean" && + (state.reason === null || typeof state.reason === "string") && + (state.pausedAt === null || typeof state.pausedAt === "string") + ); +} + +export const defaultGovernorPauseState = (): GovernorPauseState => ({ paused: false, reason: null, pausedAt: null }); + +async function parseGovernorPauseStateResponse( + response: Response, + apiLabel: string, +): Promise { + if (!response.ok) return { ok: false, error: `${apiLabel} responded ${response.status}` }; + const payload: unknown = await response.json(); + const pauseState = (payload as { pauseState?: unknown }).pauseState; + if (!isGovernorPauseState(pauseState)) { + return { ok: false, error: `${apiLabel} returned an unexpected payload shape` }; + } + return { ok: true, pauseState }; +} + +/** Fetch the governor's current pause state; failures surface as a typed error result the view renders, never a crash. */ +export async function fetchGovernorPauseState(fetchImpl: typeof fetch = fetch): Promise { + try { + const response = await fetchImpl(GOVERNOR_PAUSE_STATE_API_PATH); + return await parseGovernorPauseStateResponse(response, "local governor pause-state API"); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local governor pause-state API", + }; + } +} + +async function postGovernorAction( + path: string, + body: Record, + fetchImpl: typeof fetch, +): Promise { + try { + const response = await fetchImpl(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return await parseGovernorPauseStateResponse(response, "local governor action API"); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local governor action API", + }; + } +} + +/** Pause the governor, optionally with a reason (mirrors `gittensory-miner governor pause [--reason ]`). */ +export function pauseGovernor(reason?: string, fetchImpl: typeof fetch = fetch): Promise { + return postGovernorAction(GOVERNOR_PAUSE_API_PATH, reason ? { reason } : {}, fetchImpl); +} + +/** Resume the governor (mirrors `gittensory-miner governor resume`). */ +export function resumeGovernor(fetchImpl: typeof fetch = fetch): Promise { + return postGovernorAction(GOVERNOR_RESUME_API_PATH, {}, fetchImpl); +} diff --git a/apps/gittensory-miner-ui/src/routes/ledgers.tsx b/apps/gittensory-miner-ui/src/routes/ledgers.tsx index 210b82198..6e860a9c9 100644 --- a/apps/gittensory-miner-ui/src/routes/ledgers.tsx +++ b/apps/gittensory-miner-ui/src/routes/ledgers.tsx @@ -1,10 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { Button } from "@loopover/ui-kit/components/button"; import { Card, CardContent, CardHeader } from "@loopover/ui-kit/components/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@loopover/ui-kit/components/table"; import { CLAIM_STATUSES, fetchLedgers, type ClaimStatus, type LedgersResult } from "../lib/ledgers"; +import { fetchGovernorPauseState, pauseGovernor, resumeGovernor, type GovernorPauseStateResult } from "../lib/governor"; export const Route = createFileRoute("/ledgers")({ component: LedgersPage, @@ -14,6 +16,11 @@ export const Route = createFileRoute("/ledgers")({ // 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). +// +// The governor control section below is a SEPARATE fetch/action loop from the read-only ledger summary above +// (#4857, the governor half): it reads/writes the governor's pause state via vite-governor-api.ts, the +// miner-ui's first write-capable endpoint, safe only because vite-auth.ts (#4858) now authenticates every +// /api/* request. It does not touch, and is unrelated to, the governor EVENT ledger already shown below. const CLAIM_STATUS_LABELS: Record = { active: "Active", @@ -49,6 +56,48 @@ function CountTable({ counts, keyLabel }: { counts: Record; keyL ); } +export function GovernorControlSection({ + result, + pending, + onPause, + onResume, +}: { + result: GovernorPauseStateResult | null; + pending: boolean; + onPause: () => void; + onResume: () => void; +}) { + return ( +
+

Governor control

+ {result === null ? ( +

Loading governor state…

+ ) : !result.ok ? ( +

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

+ ) : ( +
+

+ {result.pauseState.paused + ? `Paused since ${result.pauseState.pausedAt}${result.pauseState.reason ? ` (${result.pauseState.reason})` : ""}` + : "Not paused"} +

+ {result.pauseState.paused ? ( + + ) : ( + + )} +
+ )} +
+ ); +} + export function LedgersView({ result }: { result: LedgersResult | null }) { if (result === null) { return

Loading local ledgers…

; @@ -126,8 +175,20 @@ export function LedgersView({ result }: { result: LedgersResult | null }) { ); } -export function LedgersPage({ loadLedgers = fetchLedgers }: { loadLedgers?: () => Promise }) { +export function LedgersPage({ + loadLedgers = fetchLedgers, + loadGovernorPauseState = fetchGovernorPauseState, + pauseGovernorAction = pauseGovernor, + resumeGovernorAction = resumeGovernor, +}: { + loadLedgers?: () => Promise; + loadGovernorPauseState?: () => Promise; + pauseGovernorAction?: () => Promise; + resumeGovernorAction?: () => Promise; +}) { const [result, setResult] = useState(null); + const [pauseState, setPauseState] = useState(null); + const [actionPending, setActionPending] = useState(false); useEffect(() => { let cancelled = false; @@ -139,6 +200,24 @@ export function LedgersPage({ loadLedgers = fetchLedgers }: { loadLedgers?: () = }; }, [loadLedgers]); + useEffect(() => { + let cancelled = false; + void loadGovernorPauseState().then((loaded) => { + if (!cancelled) setPauseState(loaded); + }); + return () => { + cancelled = true; + }; + }, [loadGovernorPauseState]); + + const runGovernorAction = (action: () => Promise) => { + setActionPending(true); + void action().then((next) => { + setPauseState(next); + setActionPending(false); + }); + }; + return ( @@ -148,7 +227,15 @@ export function LedgersPage({ loadLedgers = fetchLedgers }: { loadLedgers?: () =

- +
+ runGovernorAction(pauseGovernorAction)} + onResume={() => runGovernorAction(resumeGovernorAction)} + /> + +
); diff --git a/apps/gittensory-miner-ui/vite-governor-api.ts b/apps/gittensory-miner-ui/vite-governor-api.ts new file mode 100644 index 000000000..a40fb0531 --- /dev/null +++ b/apps/gittensory-miner-ui/vite-governor-api.ts @@ -0,0 +1,165 @@ +import { existsSync } from "node:fs"; +import type { Plugin } from "vite"; + +// Governor pause/resume control surface for the miner-ui (#4857, the governor half): the miner-ui was 100% +// read-only until #4858 added real authentication to /api/* -- this is the first write-capable endpoint, and it +// only exists because that auth gate now covers it automatically (registered after authPlugin() in +// vite.config.ts's plugin list, same as the read-only siblings). It is a thin bridge to the EXISTING +// governor-state.js exports (`loadPauseState`/`savePauseState`) the CLI's `governor pause`/`governor resume`/ +// `governor status` subcommands already use (governor-pause-cli.js) -- no new pause/resume semantics are +// invented here, and this file never touches governor-chokepoint.js/governor-chokepoint-persisted.js (the +// governor's actual decision-to-proceed logic stays untouched, per #4857's own scope note). +// +// Queue release/requeue actions (the other half of #4857) are deliberately NOT included here: they need a +// per-item `identifier`, which the read-only portfolio-queue API intentionally never republishes over the wire +// (see vite-portfolio-queue-api.ts's own header comment) -- exposing identifiers safely is a separate design +// question, not a trivial wire-up, and is left for a follow-up. +// +// Same read-only-safe fresh-install rule as the sibling GET endpoints for the READ route only: `loadPauseState()` +// lazily initializes the default store, which would CREATE the SQLite file (a write) on a fresh install -- so +// GET checks the resolved DB path for existence first and serves the "not paused" default without ever opening +// the store. The two POST routes have no such fast path: pausing/resuming on a fresh install is expected to +// create the store, exactly like the CLI's own `governor pause`/`governor resume` commands already do. +// +// matchGovernorRoute() is checked SYNCHRONOUSLY, before ever reading a request body: every other request this +// middleware sees (every page/asset load, every OTHER /api/* route) must fall through to `next()` immediately, +// without this plugin consuming (or even touching) a request stream it has no business reading. + +type GovernorPauseState = { paused: boolean; reason: string | null; pausedAt: string | null }; + +type GovernorStateModule = { + resolveGovernorStateDbPath: () => string; + loadPauseState: () => GovernorPauseState; + savePauseState: (input: { paused: boolean; reason?: string | null }) => GovernorPauseState; +}; + +export type GovernorApiDeps = { + /** Import of `packages/gittensory-miner/lib/governor-state.js` — injectable so tests never touch a real store. */ + loadGovernorStateModule: () => Promise; + /** File-existence probe for the fresh-install fast path on the GET route. */ + fileExists: (path: string) => boolean; +}; + +const defaultDeps: GovernorApiDeps = { + loadGovernorStateModule: () => + import("../../packages/gittensory-miner/lib/governor-state.js") as Promise, + fileExists: existsSync, +}; + +function notPausedDefault(): GovernorPauseState { + return { paused: false, reason: null, pausedAt: null }; +} + +type GovernorRoute = "pause-state-get" | "pause-post" | "resume-post"; + +/** Pure route matcher, no I/O — safe to call synchronously before deciding whether to read a request body at + * all. Exported so the plugin's own pre-check and `handleGovernorRequest` share exactly one definition of + * "which of the three governor routes (if any) does this request match." */ +export function matchGovernorRoute(method: string | undefined, url: string | undefined): GovernorRoute | null { + if (url === "/api/governor/pause-state" && (method === undefined || method === "GET")) return "pause-state-get"; + if (url === "/api/governor/pause" && method === "POST") return "pause-post"; + if (url === "/api/governor/resume" && method === "POST") return "resume-post"; + return null; +} + +/** Collects a request body into a string. Every route here has a small (or empty) JSON body, so no size cap is + * needed beyond what Node's own default HTTP request-size limits already enforce. */ +function readRequestBody(req: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk: Buffer | string) => { + body += chunk.toString(); + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +/** Parses an optional `{ reason?: string }` POST body for the pause route. An empty body, invalid JSON, or a + * non-string `reason` all fall back to no reason rather than failing the request — a malformed body is not + * worth rejecting a genuine pause request over. */ +function parsePauseReason(rawBody: string): string | undefined { + if (!rawBody.trim()) return undefined; + try { + const parsed: unknown = JSON.parse(rawBody); + const reason = (parsed as { reason?: unknown })?.reason; + return typeof reason === "string" && reason.trim() ? reason.trim() : undefined; + } catch { + return undefined; + } +} + +/** Executes an ALREADY-MATCHED governor route. Never returns null — callers (the plugin middleware and + * `handleGovernorRequest` below) only reach this once `matchGovernorRoute` has confirmed a match, so there is + * no "not my route" case left to represent here. */ +async function respondToGovernorRoute( + route: GovernorRoute, + rawBody: string, + deps: GovernorApiDeps, +): Promise<{ status: number; body: string }> { + try { + const governor = await deps.loadGovernorStateModule(); + if (route === "pause-state-get") { + if (!deps.fileExists(governor.resolveGovernorStateDbPath())) { + return { status: 200, body: JSON.stringify({ pauseState: notPausedDefault() }) }; + } + return { status: 200, body: JSON.stringify({ pauseState: governor.loadPauseState() }) }; + } + if (route === "pause-post") { + const pauseState = governor.savePauseState({ paused: true, reason: parsePauseReason(rawBody) }); + return { status: 200, body: JSON.stringify({ pauseState }) }; + } + const pauseState = governor.savePauseState({ paused: false }); // route === "resume-post" + return { status: 200, body: JSON.stringify({ pauseState }) }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to update the local governor pause state"; + return { status: 500, body: JSON.stringify({ error: message }) }; + } +} + +/** The request handler, factored out of the Vite plugin shape so tests drive it directly (mirrors the sibling + * API files' handleXRequest pattern). Returns null when the request is for none of the three governor routes. */ +export async function handleGovernorRequest( + method: string | undefined, + url: string | undefined, + rawBody: string, + deps: GovernorApiDeps = defaultDeps, +): Promise<{ status: number; body: string } | null> { + const route = matchGovernorRoute(method, url); + if (!route) return null; + return respondToGovernorRoute(route, rawBody, deps); +} + +/** Vite dev/preview middleware serving the governor pause-state read + pause/resume write endpoints. */ +export function governorApiPlugin(deps: GovernorApiDeps = defaultDeps): Plugin { + const attach = (middlewares: { + use: ( + fn: ( + req: { method?: string; url?: string } & NodeJS.ReadableStream, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, + ) => void, + ) => void; + }) => { + middlewares.use((req, res, next) => { + const route = matchGovernorRoute(req.method, req.url); + if (!route) return next(); + void readRequestBody(req) + .then((rawBody) => respondToGovernorRoute(route, rawBody, deps)) + .then((handled) => { + res.statusCode = handled.status; + res.setHeader("Content-Type", "application/json"); + res.end(handled.body); + }); + }); + }; + return { + name: "gittensory-miner-ui:governor-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 097dd093c..b8a4d54b4 100644 --- a/apps/gittensory-miner-ui/vite.config.ts +++ b/apps/gittensory-miner-ui/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { authPlugin } from "./vite-auth"; +import { governorApiPlugin } from "./vite-governor-api"; import { ledgersApiPlugin } from "./vite-ledgers-api"; import { portfolioQueueApiPlugin } from "./vite-portfolio-queue-api"; import { runStateApiPlugin } from "./vite-run-state-api"; @@ -15,12 +16,13 @@ export default defineConfig({ react(), tailwindcss(), tsconfigPaths(), - // Must run before the three API plugins below: it rejects any unauthenticated /api/* request before - // their own middlewares are reached (#4858). + // Must run before the API plugins below: it rejects any unauthenticated /api/* request before their own + // middlewares are reached (#4858). authPlugin(), runStateApiPlugin(), portfolioQueueApiPlugin(), ledgersApiPlugin(), + governorApiPlugin(), ], server: { // Offset from gittensory-ui (5173) so both apps can run side-by-side locally.