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 ? (
+
+ Resume governor
+
+ ) : (
+
+ Pause governor
+
+ )}
+
+ )}
+
+ );
+}
+
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.