diff --git a/apps/gittensory-miner-ui/src/lib/queue-actions.ts b/apps/gittensory-miner-ui/src/lib/queue-actions.ts
new file mode 100644
index 0000000000..9e7b50bc22
--- /dev/null
+++ b/apps/gittensory-miner-ui/src/lib/queue-actions.ts
@@ -0,0 +1,112 @@
+// Client for the local queue-actions read + release/requeue write API (#4857, the queue half of "Add real
+// actions to the miner-ui"). Mirrors governor.ts's shape (typed result unions, no throw on a bad response, a
+// guard narrowing the parsed JSON payload) — the miner-ui's second write surface, safe for the same reason
+// governor.ts's actions are: vite-auth.ts (#4858) authenticates every /api/* request, including these.
+
+export const QUEUE_ACTIONABLE_API_PATH = "/api/queue/actionable";
+export const QUEUE_RELEASE_API_PATH = "/api/queue/release";
+export const QUEUE_REQUEUE_API_PATH = "/api/queue/requeue";
+
+export type ReleasableItem = { apiBaseUrl: string; repoFullName: string; identifier: string; leasedAt: string | null };
+export type RequeueableItem = { apiBaseUrl: string; repoFullName: string; identifier: string; enqueuedAt: string };
+
+export type QueueActionableResult =
+ { ok: true; releasable: ReleasableItem[]; requeueable: RequeueableItem[] } | { ok: false; error: string };
+
+export type QueueActionEntry = {
+ apiBaseUrl: string;
+ repoFullName: string;
+ identifier: string;
+ status: string;
+ enqueuedAt: string;
+};
+
+export type QueueActionResult = { ok: true; entry: QueueActionEntry } | { ok: false; error: string };
+
+function isReleasableItem(value: unknown): value is ReleasableItem {
+ if (typeof value !== "object" || value === null) return false;
+ const item = value as Record;
+ return (
+ typeof item.apiBaseUrl === "string" &&
+ typeof item.repoFullName === "string" &&
+ typeof item.identifier === "string" &&
+ (item.leasedAt === null || typeof item.leasedAt === "string")
+ );
+}
+
+function isRequeueableItem(value: unknown): value is RequeueableItem {
+ if (typeof value !== "object" || value === null) return false;
+ const item = value as Record;
+ return (
+ typeof item.apiBaseUrl === "string" &&
+ typeof item.repoFullName === "string" &&
+ typeof item.identifier === "string" &&
+ typeof item.enqueuedAt === "string"
+ );
+}
+
+/** Fetch the actionable-items snapshot (in-flight items releasable, completed items requeueable); failures
+ * surface as a typed error result the view renders, never a crash. */
+export async function fetchQueueActionable(fetchImpl: typeof fetch = fetch): Promise {
+ try {
+ const response = await fetchImpl(QUEUE_ACTIONABLE_API_PATH);
+ if (!response.ok) return { ok: false, error: `local queue-actionable API responded ${response.status}` };
+ const payload = (await response.json()) as { releasable?: unknown; requeueable?: unknown };
+ if (
+ !Array.isArray(payload.releasable) ||
+ !payload.releasable.every(isReleasableItem) ||
+ !Array.isArray(payload.requeueable) ||
+ !payload.requeueable.every(isRequeueableItem)
+ ) {
+ return { ok: false, error: "local queue-actionable API returned an unexpected payload shape" };
+ }
+ return { ok: true, releasable: payload.releasable, requeueable: payload.requeueable };
+ } catch (error) {
+ return {
+ ok: false,
+ error: error instanceof Error ? error.message : "failed to reach the local queue-actionable API",
+ };
+ }
+}
+
+async function postQueueAction(
+ path: string,
+ target: { repoFullName: string; identifier: string; apiBaseUrl: string },
+ fetchImpl: typeof fetch,
+): Promise {
+ try {
+ // Only the three fields the API reads, never the whole (possibly wider) item object a caller passed in --
+ // ReleasableItem/RequeueableItem carry an extra leasedAt/enqueuedAt field the server has no use for.
+ const { repoFullName, identifier, apiBaseUrl } = target;
+ const response = await fetchImpl(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ repoFullName, identifier, apiBaseUrl }),
+ });
+ const payload = (await response.json()) as { ok?: unknown; entry?: unknown; error?: unknown };
+ if (payload.ok === true && typeof payload.entry === "object" && payload.entry !== null) {
+ return { ok: true, entry: payload.entry as QueueActionEntry };
+ }
+ const error =
+ typeof payload.error === "string" ? payload.error : `local queue action API responded ${response.status}`;
+ return { ok: false, error };
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : "failed to reach the local queue action API" };
+ }
+}
+
+/** Release an in-flight item back to the queue (mirrors `gittensory-miner queue release `). */
+export function releaseQueueItem(
+ target: { repoFullName: string; identifier: string; apiBaseUrl: string },
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ return postQueueAction(QUEUE_RELEASE_API_PATH, target, fetchImpl);
+}
+
+/** Requeue a completed item so it is picked up again (mirrors `gittensory-miner queue requeue `). */
+export function requeueQueueItem(
+ target: { repoFullName: string; identifier: string; apiBaseUrl: string },
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ return postQueueAction(QUEUE_REQUEUE_API_PATH, target, fetchImpl);
+}
diff --git a/apps/gittensory-miner-ui/src/queue-actions.test.tsx b/apps/gittensory-miner-ui/src/queue-actions.test.tsx
new file mode 100644
index 0000000000..6751757fe9
--- /dev/null
+++ b/apps/gittensory-miner-ui/src/queue-actions.test.tsx
@@ -0,0 +1,861 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ fetchQueueActionable,
+ QUEUE_ACTIONABLE_API_PATH,
+ QUEUE_RELEASE_API_PATH,
+ QUEUE_REQUEUE_API_PATH,
+ releaseQueueItem,
+ requeueQueueItem,
+ type QueueActionableResult,
+ type QueueActionResult,
+ type ReleasableItem,
+ type RequeueableItem,
+} from "./lib/queue-actions";
+import { emptyPortfolioQueueSummary } from "./lib/portfolio-queue";
+import { PortfolioPage, QueueActionsSection, queueItemKey } from "./routes/portfolio";
+import {
+ handleQueueActionsRequest,
+ matchQueueActionsRoute,
+ queueActionsApiPlugin,
+ type QueueActionsApiDeps,
+} from "../vite-queue-actions-api";
+
+const releasable: ReleasableItem = {
+ apiBaseUrl: "https://api.github.com",
+ repoFullName: "acme/widgets",
+ identifier: "issue-1",
+ leasedAt: "2026-07-13T12:00:00.000Z",
+};
+
+const requeueable: RequeueableItem = {
+ apiBaseUrl: "https://api.github.com",
+ repoFullName: "acme/widgets",
+ identifier: "issue-2",
+ enqueuedAt: "2026-07-13T11:00:00.000Z",
+};
+
+describe("queueItemKey (#4857)", () => {
+ it("joins apiBaseUrl/repoFullName/identifier into one stable string", () => {
+ expect(queueItemKey(releasable)).toBe("https://api.github.com|acme/widgets|issue-1");
+ });
+});
+
+describe("QueueActionsSection (#4857)", () => {
+ it("renders the loading state before the first result arrives", () => {
+ render(
+ undefined}
+ onRequeue={() => undefined}
+ />,
+ );
+ expect(screen.getByText(/Loading actionable queue items/i)).toBeTruthy();
+ });
+
+ it("renders an error message when the local API is unreachable", () => {
+ render(
+ undefined}
+ onRequeue={() => undefined}
+ />,
+ );
+ expect(screen.getByRole("alert").textContent).toContain("connection refused");
+ });
+
+ it("renders an empty state when there is nothing to act on", () => {
+ render(
+ undefined}
+ onRequeue={() => undefined}
+ />,
+ );
+ expect(screen.getByText(/No in-progress or completed items to act on/i)).toBeTruthy();
+ });
+
+ it("renders a releasable row and calls onRelease with the item when clicked", () => {
+ const onRelease = vi.fn();
+ render(
+ undefined}
+ />,
+ );
+ expect(screen.getByText("issue-1")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Release" }));
+ expect(onRelease).toHaveBeenCalledWith(releasable);
+ });
+
+ it("shows an em dash for a releasable row with no leasedAt", () => {
+ render(
+ undefined}
+ onRequeue={() => undefined}
+ />,
+ );
+ expect(screen.getByText("—")).toBeTruthy();
+ });
+
+ it("renders a requeueable row and calls onRequeue with the item when clicked", () => {
+ const onRequeue = vi.fn();
+ render(
+ undefined}
+ onRequeue={onRequeue}
+ />,
+ );
+ expect(screen.getByText("issue-2")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Requeue" }));
+ expect(onRequeue).toHaveBeenCalledWith(requeueable);
+ });
+
+ it("disables only the pending row's button, not other rows'", () => {
+ const other: ReleasableItem = { ...releasable, identifier: "issue-3" };
+ render(
+ undefined}
+ onRequeue={() => undefined}
+ />,
+ );
+ const buttons = screen.getAllByRole("button", { name: "Release" }) as HTMLButtonElement[];
+ expect(buttons[0]?.disabled).toBe(true);
+ expect(buttons[1]?.disabled).toBe(false);
+ });
+
+ it("renders an action-error alert above the item lists", () => {
+ render(
+ undefined}
+ onRequeue={() => undefined}
+ />,
+ );
+ expect(screen.getByRole("alert").textContent).toContain("queue_entry_not_in_progress");
+ });
+});
+
+describe("fetchQueueActionable / releaseQueueItem / requeueQueueItem (#4857)", () => {
+ const jsonResponse = (status: number, payload: unknown) =>
+ ({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response;
+
+ it("fetchQueueActionable returns a typed snapshot from a well-formed payload, requesting the local API path", async () => {
+ let requested: string | undefined;
+ const result = await fetchQueueActionable(async (input) => {
+ requested = String(input);
+ return jsonResponse(200, { releasable: [releasable], requeueable: [requeueable] });
+ });
+ expect(requested).toBe(QUEUE_ACTIONABLE_API_PATH);
+ expect(result).toEqual({ ok: true, releasable: [releasable], requeueable: [requeueable] });
+ });
+
+ it("fetchQueueActionable surfaces non-2xx, malformed payloads, and thrown fetches as typed errors", async () => {
+ expect(await fetchQueueActionable(async () => jsonResponse(500, {}))).toEqual({
+ ok: false,
+ error: "local queue-actionable API responded 500",
+ });
+ expect(
+ await fetchQueueActionable(async () =>
+ jsonResponse(200, { releasable: [{ repoFullName: "x" }], requeueable: [] }),
+ ),
+ ).toMatchObject({ ok: false });
+ expect(
+ await fetchQueueActionable(async () => jsonResponse(200, { releasable: [], requeueable: [{ identifier: "x" }] })),
+ ).toMatchObject({ ok: false });
+ expect(
+ await fetchQueueActionable(async () => jsonResponse(200, { releasable: [null], requeueable: [] })),
+ ).toMatchObject({ ok: false });
+ expect(
+ await fetchQueueActionable(async () => jsonResponse(200, { releasable: [], requeueable: ["not-an-object"] })),
+ ).toMatchObject({ ok: false });
+ expect(
+ await fetchQueueActionable(async () => {
+ throw new Error("connection refused");
+ }),
+ ).toEqual({ ok: false, error: "connection refused" });
+ expect(
+ await fetchQueueActionable(async () => {
+ throw "not an Error instance";
+ }),
+ ).toEqual({ ok: false, error: "failed to reach the local queue-actionable API" });
+ });
+
+ it("releaseQueueItem POSTs the target to the release path and returns the resulting entry", async () => {
+ let requested: { input: string; init: RequestInit | undefined } | undefined;
+ const entry = {
+ apiBaseUrl: releasable.apiBaseUrl,
+ repoFullName: releasable.repoFullName,
+ identifier: releasable.identifier,
+ status: "queued",
+ enqueuedAt: "2026-07-13T12:05:00.000Z",
+ };
+ const result = await releaseQueueItem(releasable, async (input, init) => {
+ requested = { input: String(input), init };
+ return jsonResponse(200, { ok: true, entry });
+ });
+ expect(requested?.input).toBe(QUEUE_RELEASE_API_PATH);
+ expect(requested?.init?.method).toBe("POST");
+ expect(JSON.parse(String(requested?.init?.body))).toEqual({
+ apiBaseUrl: releasable.apiBaseUrl,
+ repoFullName: releasable.repoFullName,
+ identifier: releasable.identifier,
+ });
+ expect(result).toEqual({ ok: true, entry });
+ });
+
+ it("requeueQueueItem POSTs the target to the requeue path and returns the resulting entry", async () => {
+ let requested: { input: string } | undefined;
+ const entry = {
+ apiBaseUrl: requeueable.apiBaseUrl,
+ repoFullName: requeueable.repoFullName,
+ identifier: requeueable.identifier,
+ status: "queued",
+ enqueuedAt: requeueable.enqueuedAt,
+ };
+ const result = await requeueQueueItem(requeueable, async (input) => {
+ requested = { input: String(input) };
+ return jsonResponse(200, { ok: true, entry });
+ });
+ expect(requested?.input).toBe(QUEUE_REQUEUE_API_PATH);
+ expect(result).toEqual({ ok: true, entry });
+ });
+
+ it("release/requeue surface a business-outcome failure (ok:false) from the response body", async () => {
+ const result: QueueActionResult = await releaseQueueItem(releasable, async () =>
+ jsonResponse(200, { ok: false, error: "queue_entry_not_in_progress" }),
+ );
+ expect(result).toEqual({ ok: false, error: "queue_entry_not_in_progress" });
+ });
+
+ it("release/requeue fall back to a status-derived message when the failure body has no error string", async () => {
+ const result: QueueActionResult = await releaseQueueItem(releasable, async () => jsonResponse(400, { ok: false }));
+ expect(result).toEqual({ ok: false, error: "local queue action API responded 400" });
+ });
+
+ it("release/requeue surface a thrown fetch as a typed error", async () => {
+ const failing: QueueActionResult = { ok: false, error: "connection refused" };
+ expect(
+ await releaseQueueItem(releasable, async () => {
+ throw new Error("connection refused");
+ }),
+ ).toEqual(failing);
+ expect(
+ await requeueQueueItem(requeueable, async () => {
+ throw new Error("connection refused");
+ }),
+ ).toEqual(failing);
+ });
+
+ it("release/requeue surface a non-Error thrown value with a generic fallback message", async () => {
+ expect(
+ await releaseQueueItem(releasable, async () => {
+ throw "not an Error instance";
+ }),
+ ).toEqual({ ok: false, error: "failed to reach the local queue action API" });
+ });
+});
+
+describe("matchQueueActionsRoute (#4857)", () => {
+ it("matches GET (or method-less) requests to /api/queue/actionable", () => {
+ expect(matchQueueActionsRoute("GET", "/api/queue/actionable")).toBe("actionable-get");
+ expect(matchQueueActionsRoute(undefined, "/api/queue/actionable")).toBe("actionable-get");
+ });
+
+ it("matches POST /api/queue/release and /api/queue/requeue", () => {
+ expect(matchQueueActionsRoute("POST", "/api/queue/release")).toBe("release-post");
+ expect(matchQueueActionsRoute("POST", "/api/queue/requeue")).toBe("requeue-post");
+ });
+
+ it("matches nothing for any other method/path combination", () => {
+ expect(matchQueueActionsRoute("POST", "/api/queue/actionable")).toBeNull();
+ expect(matchQueueActionsRoute("GET", "/api/queue/release")).toBeNull();
+ expect(matchQueueActionsRoute("GET", "/api/portfolio-queue")).toBeNull();
+ });
+});
+
+describe("handleQueueActionsRequest (#4857)", () => {
+ const inProgressRow = {
+ apiBaseUrl: "https://api.github.com",
+ repoFullName: "acme/widgets",
+ identifier: "issue-1",
+ status: "in_progress",
+ leasedAt: "2026-07-13T12:00:00.000Z",
+ };
+ const doneRow = {
+ apiBaseUrl: "https://api.github.com",
+ repoFullName: "acme/widgets",
+ identifier: "issue-2",
+ status: "done",
+ enqueuedAt: "2026-07-13T11:00:00.000Z",
+ };
+ const queuedRow = {
+ apiBaseUrl: "https://api.github.com",
+ repoFullName: "acme/widgets",
+ identifier: "issue-3",
+ status: "queued",
+ enqueuedAt: "2026-07-13T10:00:00.000Z",
+ };
+
+ function deps(overrides: Partial = {}): QueueActionsApiDeps {
+ return {
+ loadPortfolioQueueModule: async () => ({
+ resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3",
+ initPortfolioQueueStore: () => ({
+ listInProgress: () => [inProgressRow],
+ listQueue: () => [doneRow, queuedRow],
+ reclaimStuckItem: (repoFullName: string, identifier: string) =>
+ repoFullName === inProgressRow.repoFullName && identifier === inProgressRow.identifier
+ ? {
+ apiBaseUrl: inProgressRow.apiBaseUrl,
+ repoFullName,
+ identifier,
+ status: "queued",
+ enqueuedAt: "2026-07-13T12:05:00.000Z",
+ }
+ : null,
+ requeueItem: (repoFullName: string, identifier: string) =>
+ repoFullName === doneRow.repoFullName && identifier === doneRow.identifier
+ ? {
+ apiBaseUrl: doneRow.apiBaseUrl,
+ repoFullName,
+ identifier,
+ status: "queued",
+ enqueuedAt: doneRow.enqueuedAt,
+ }
+ : null,
+ close: () => undefined,
+ }),
+ }),
+ fileExists: () => true,
+ ...overrides,
+ };
+ }
+
+ it("falls through (null) for a request that matches none of the three queue-actions routes", async () => {
+ expect(await handleQueueActionsRequest("GET", "/api/portfolio-queue", "", deps())).toBeNull();
+ expect(await handleQueueActionsRequest("POST", "/api/queue/actionable", "", deps())).toBeNull();
+ });
+
+ it("GET actionable serves releasable (in_progress) and requeueable (done) items, stripping priority and excluding queued", async () => {
+ const handled = await handleQueueActionsRequest("GET", "/api/queue/actionable", "", deps());
+ expect(handled).toEqual({
+ status: 200,
+ body: JSON.stringify({
+ releasable: [
+ {
+ apiBaseUrl: inProgressRow.apiBaseUrl,
+ repoFullName: inProgressRow.repoFullName,
+ identifier: inProgressRow.identifier,
+ leasedAt: inProgressRow.leasedAt,
+ },
+ ],
+ requeueable: [
+ {
+ apiBaseUrl: doneRow.apiBaseUrl,
+ repoFullName: doneRow.repoFullName,
+ identifier: doneRow.identifier,
+ enqueuedAt: doneRow.enqueuedAt,
+ },
+ ],
+ }),
+ });
+ });
+
+ it("GET actionable serves an empty snapshot on a fresh install WITHOUT opening the store", async () => {
+ let opened = false;
+ const handled = await handleQueueActionsRequest(
+ "GET",
+ "/api/queue/actionable",
+ "",
+ deps({
+ fileExists: () => false,
+ loadPortfolioQueueModule: async () => ({
+ resolvePortfolioQueueDbPath: () => "/nowhere/portfolio-queue.sqlite3",
+ initPortfolioQueueStore: () => {
+ opened = true;
+ throw new Error("should not be called");
+ },
+ }),
+ }),
+ );
+ expect(handled).toEqual({ status: 200, body: JSON.stringify({ releasable: [], requeueable: [] }) });
+ expect(opened).toBe(false);
+ });
+
+ it("POST release with a matching in-progress target returns the entry with priority stripped", async () => {
+ const handled = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/release",
+ JSON.stringify({ repoFullName: inProgressRow.repoFullName, identifier: inProgressRow.identifier }),
+ deps(),
+ );
+ expect(handled).toEqual({
+ status: 200,
+ body: JSON.stringify({
+ ok: true,
+ entry: {
+ apiBaseUrl: inProgressRow.apiBaseUrl,
+ repoFullName: inProgressRow.repoFullName,
+ identifier: inProgressRow.identifier,
+ status: "queued",
+ enqueuedAt: "2026-07-13T12:05:00.000Z",
+ },
+ }),
+ });
+ });
+
+ it("POST requeue with a matching done target returns the entry", async () => {
+ const handled = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/requeue",
+ JSON.stringify({ repoFullName: doneRow.repoFullName, identifier: doneRow.identifier }),
+ deps(),
+ );
+ expect(handled).toEqual({
+ status: 200,
+ body: JSON.stringify({
+ ok: true,
+ entry: {
+ apiBaseUrl: doneRow.apiBaseUrl,
+ repoFullName: doneRow.repoFullName,
+ identifier: doneRow.identifier,
+ status: "queued",
+ enqueuedAt: doneRow.enqueuedAt,
+ },
+ }),
+ });
+ });
+
+ it("POST release/requeue on a non-matching target returns a typed business-outcome failure, not an error status", async () => {
+ const release = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/release",
+ JSON.stringify({ repoFullName: "nowhere/nothing", identifier: "issue-9" }),
+ deps(),
+ );
+ expect(release).toEqual({ status: 200, body: JSON.stringify({ ok: false, error: "queue_entry_not_in_progress" }) });
+ const requeue = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/requeue",
+ JSON.stringify({ repoFullName: "nowhere/nothing", identifier: "issue-9" }),
+ deps(),
+ );
+ expect(requeue).toEqual({ status: 200, body: JSON.stringify({ ok: false, error: "queue_entry_not_requeuable" }) });
+ });
+
+ it("POST release/requeue on a fresh install (no store yet) returns the not-actionable outcome WITHOUT opening the store", async () => {
+ let opened = false;
+ const freshDeps = deps({
+ fileExists: () => false,
+ loadPortfolioQueueModule: async () => ({
+ resolvePortfolioQueueDbPath: () => "/nowhere/portfolio-queue.sqlite3",
+ initPortfolioQueueStore: () => {
+ opened = true;
+ throw new Error("should not be called");
+ },
+ }),
+ });
+ const release = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/release",
+ JSON.stringify({ repoFullName: "acme/widgets", identifier: "issue-1" }),
+ freshDeps,
+ );
+ expect(release).toEqual({ status: 200, body: JSON.stringify({ ok: false, error: "queue_entry_not_in_progress" }) });
+ const requeue = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/requeue",
+ JSON.stringify({ repoFullName: "acme/widgets", identifier: "issue-1" }),
+ freshDeps,
+ );
+ expect(requeue).toEqual({ status: 200, body: JSON.stringify({ ok: false, error: "queue_entry_not_requeuable" }) });
+ expect(opened).toBe(false);
+ });
+
+ it("threads an explicit apiBaseUrl from the request body through to the store call", async () => {
+ let receivedApiBaseUrl: string | undefined;
+ const handled = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/release",
+ JSON.stringify({
+ repoFullName: inProgressRow.repoFullName,
+ identifier: inProgressRow.identifier,
+ apiBaseUrl: "https://ghe.example.com/api/v3",
+ }),
+ deps({
+ loadPortfolioQueueModule: async () => ({
+ resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3",
+ initPortfolioQueueStore: () => ({
+ listInProgress: () => [inProgressRow],
+ listQueue: () => [doneRow, queuedRow],
+ reclaimStuckItem: (repoFullName: string, identifier: string, apiBaseUrl?: string) => {
+ receivedApiBaseUrl = apiBaseUrl;
+ return {
+ apiBaseUrl: apiBaseUrl ?? inProgressRow.apiBaseUrl,
+ repoFullName,
+ identifier,
+ status: "queued",
+ enqueuedAt: "2026-07-13T12:05:00.000Z",
+ };
+ },
+ requeueItem: () => null,
+ close: () => undefined,
+ }),
+ }),
+ }),
+ );
+ expect(receivedApiBaseUrl).toBe("https://ghe.example.com/api/v3");
+ expect(handled?.status).toBe(200);
+ });
+
+ it("POST release/requeue rejects an empty, malformed, or field-missing body with 400", async () => {
+ const empty = await handleQueueActionsRequest("POST", "/api/queue/release", "", deps());
+ expect(empty).toEqual({ status: 400, body: JSON.stringify({ error: "invalid_request_body" }) });
+ const malformed = await handleQueueActionsRequest("POST", "/api/queue/release", "{not json", deps());
+ expect(malformed).toEqual({ status: 400, body: JSON.stringify({ error: "invalid_request_body" }) });
+ const missingIdentifier = await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/requeue",
+ JSON.stringify({ repoFullName: "acme/widgets" }),
+ deps(),
+ );
+ expect(missingIdentifier).toEqual({ status: 400, body: JSON.stringify({ error: "invalid_request_body" }) });
+ });
+
+ it("surfaces a store failure as a 500 with a safe message, for both read and write routes", async () => {
+ const brokenDeps = deps({
+ loadPortfolioQueueModule: async () => {
+ throw new Error("sqlite locked");
+ },
+ });
+ expect(await handleQueueActionsRequest("GET", "/api/queue/actionable", "", brokenDeps)).toEqual({
+ status: 500,
+ body: JSON.stringify({ error: "sqlite locked" }),
+ });
+ expect(
+ await handleQueueActionsRequest(
+ "POST",
+ "/api/queue/release",
+ JSON.stringify({ repoFullName: "a/b", identifier: "1" }),
+ brokenDeps,
+ ),
+ ).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) });
+ });
+
+ it("surfaces a non-Error thrown value with a generic fallback message", async () => {
+ const brokenDeps = deps({
+ loadPortfolioQueueModule: async () => {
+ throw "not an Error instance";
+ },
+ });
+ expect(await handleQueueActionsRequest("GET", "/api/queue/actionable", "", brokenDeps)).toEqual({
+ status: 500,
+ body: JSON.stringify({ error: "failed to update the local portfolio queue" }),
+ });
+ });
+});
+
+type FakeReq = { method?: string; url?: string } & NodeJS.ReadableStream;
+
+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 = queueActionsApiPlugin(
+ deps
+ ? {
+ loadPortfolioQueueModule: async () => ({
+ resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3",
+ initPortfolioQueueStore: () => ({
+ listInProgress: () => [],
+ listQueue: () => [],
+ reclaimStuckItem: () => null,
+ requeueItem: () => null,
+ close: () => undefined,
+ }),
+ }),
+ 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("queueActionsApiPlugin 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("queueActionsApiPlugin (#4857)", () => {
+ it("falls through to next() for a request that matches none of the three queue-actions routes, never reading its body", async () => {
+ const middleware = captureMiddleware();
+ const { res } = fakeResponse();
+ let calledNext = false;
+ middleware(fakeRequest("GET", "/api/portfolio-queue"), res, () => {
+ calledNext = true;
+ });
+ expect(calledNext).toBe(true);
+ });
+
+ it("serves GET /api/queue/actionable from the real (injected) store", async () => {
+ const middleware = captureMiddleware({
+ loadPortfolioQueueModule: async () => ({
+ resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3",
+ initPortfolioQueueStore: () => ({
+ listInProgress: () => [
+ {
+ apiBaseUrl: "https://api.github.com",
+ repoFullName: "acme/widgets",
+ identifier: "issue-1",
+ status: "in_progress",
+ leasedAt: null,
+ },
+ ],
+ listQueue: () => [],
+ reclaimStuckItem: () => null,
+ requeueItem: () => null,
+ close: () => undefined,
+ }),
+ }),
+ });
+ const { res, getEnded, getStatus } = fakeResponse();
+ middleware(fakeRequest("GET", "/api/queue/actionable"), res, () => undefined);
+ await vi.waitFor(() => expect(getEnded()).toBeDefined());
+ expect(getStatus()).toBe(200);
+ expect(JSON.parse(getEnded() ?? "{}")).toEqual({
+ releasable: [
+ { apiBaseUrl: "https://api.github.com", repoFullName: "acme/widgets", identifier: "issue-1", leasedAt: null },
+ ],
+ requeueable: [],
+ });
+ });
+
+ it("reads a POST body and releases via the real (injected) store", async () => {
+ const middleware = captureMiddleware({
+ loadPortfolioQueueModule: async () => ({
+ resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3",
+ initPortfolioQueueStore: () => ({
+ listInProgress: () => [],
+ listQueue: () => [],
+ reclaimStuckItem: (repoFullName: string, identifier: string) => ({
+ apiBaseUrl: "https://api.github.com",
+ repoFullName,
+ identifier,
+ status: "queued",
+ enqueuedAt: "2026-07-13T12:05:00.000Z",
+ }),
+ requeueItem: () => null,
+ close: () => undefined,
+ }),
+ }),
+ });
+ const { res, getEnded, getStatus } = fakeResponse();
+ middleware(
+ fakeRequest(
+ "POST",
+ "/api/queue/release",
+ JSON.stringify({ repoFullName: "acme/widgets", identifier: "issue-1" }),
+ ),
+ res,
+ () => undefined,
+ );
+ await vi.waitFor(() => expect(getEnded()).toBeDefined());
+ expect(getStatus()).toBe(200);
+ expect(JSON.parse(getEnded() ?? "{}")).toEqual({
+ ok: true,
+ entry: {
+ apiBaseUrl: "https://api.github.com",
+ repoFullName: "acme/widgets",
+ identifier: "issue-1",
+ status: "queued",
+ enqueuedAt: "2026-07-13T12:05:00.000Z",
+ },
+ });
+ });
+
+ it("also attaches via configurePreviewServer for `vite preview`", () => {
+ let captured: CapturedRequestHandler | undefined;
+ const plugin = queueActionsApiPlugin();
+ 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");
+ });
+});
+
+describe("PortfolioPage queue-actions wiring (#4857)", () => {
+ const portfolioResult = { ok: true as const, summary: emptyPortfolioQueueSummary() };
+
+ it("loads the actionable snapshot on mount and re-fetches it after a successful action", async () => {
+ let actionableCalls = 0;
+ const loadQueueActionable = vi.fn(async (): Promise => {
+ actionableCalls += 1;
+ return actionableCalls === 1
+ ? { ok: true, releasable: [releasable], requeueable: [] }
+ : { ok: true, releasable: [], requeueable: [] };
+ });
+ const releaseAction = vi.fn(async (): Promise => ({
+ ok: true,
+ entry: {
+ apiBaseUrl: releasable.apiBaseUrl,
+ repoFullName: releasable.repoFullName,
+ identifier: releasable.identifier,
+ status: "queued",
+ enqueuedAt: "2026-07-13T12:05:00.000Z",
+ },
+ }));
+
+ render(
+ portfolioResult}
+ loadQueueActionable={loadQueueActionable}
+ releaseAction={releaseAction}
+ requeueAction={async () => ({
+ ok: true,
+ entry: { apiBaseUrl: "", repoFullName: "", identifier: "", status: "", enqueuedAt: "" },
+ })}
+ pollIntervalMs={60_000}
+ />,
+ );
+
+ await screen.findByText("issue-1");
+ fireEvent.click(screen.getByRole("button", { name: "Release" }));
+ expect(releaseAction).toHaveBeenCalledWith(releasable);
+ await screen.findByText(/No in-progress or completed items to act on/i);
+ expect(loadQueueActionable).toHaveBeenCalledTimes(2);
+ });
+
+ it("shows an action-error alert and does NOT re-fetch when an action reports a business-outcome failure", async () => {
+ const loadQueueActionable = vi.fn(async (): Promise => ({
+ ok: true,
+ releasable: [releasable],
+ requeueable: [],
+ }));
+ const releaseAction = vi.fn(async (): Promise => ({
+ ok: false,
+ error: "queue_entry_not_in_progress",
+ }));
+
+ render(
+ portfolioResult}
+ loadQueueActionable={loadQueueActionable}
+ releaseAction={releaseAction}
+ requeueAction={async () => ({
+ ok: true,
+ entry: { apiBaseUrl: "", repoFullName: "", identifier: "", status: "", enqueuedAt: "" },
+ })}
+ pollIntervalMs={60_000}
+ />,
+ );
+
+ await screen.findByText("issue-1");
+ fireEvent.click(screen.getByRole("button", { name: "Release" }));
+ await screen.findByText(/queue_entry_not_in_progress/);
+ expect(loadQueueActionable).toHaveBeenCalledTimes(1);
+ });
+
+ it("wires the Requeue button to requeueAction, distinct from the Release button", async () => {
+ const loadQueueActionable = vi.fn(async (): Promise => ({
+ ok: true,
+ releasable: [],
+ requeueable: [requeueable],
+ }));
+ const requeueAction = vi.fn(async (): Promise => ({
+ ok: true,
+ entry: {
+ apiBaseUrl: requeueable.apiBaseUrl,
+ repoFullName: requeueable.repoFullName,
+ identifier: requeueable.identifier,
+ status: "queued",
+ enqueuedAt: requeueable.enqueuedAt,
+ },
+ }));
+
+ render(
+ portfolioResult}
+ loadQueueActionable={loadQueueActionable}
+ releaseAction={async () => ({
+ ok: true,
+ entry: { apiBaseUrl: "", repoFullName: "", identifier: "", status: "", enqueuedAt: "" },
+ })}
+ requeueAction={requeueAction}
+ pollIntervalMs={60_000}
+ />,
+ );
+
+ await screen.findByText("issue-2");
+ fireEvent.click(screen.getByRole("button", { name: "Requeue" }));
+ expect(requeueAction).toHaveBeenCalledWith(requeueable);
+ await vi.waitFor(() => expect(loadQueueActionable).toHaveBeenCalledTimes(2));
+ });
+});
diff --git a/apps/gittensory-miner-ui/src/routes/portfolio.tsx b/apps/gittensory-miner-ui/src/routes/portfolio.tsx
index ee106dfc95..8c741c845b 100644
--- a/apps/gittensory-miner-ui/src/routes/portfolio.tsx
+++ b/apps/gittensory-miner-ui/src/routes/portfolio.tsx
@@ -1,10 +1,21 @@
import { createFileRoute } from "@tanstack/react-router";
+import { useCallback, 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 { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
import { fetchPortfolioQueue, type PortfolioQueueResult, type QueueStatus } from "../lib/portfolio-queue";
+import {
+ fetchQueueActionable,
+ releaseQueueItem,
+ requeueQueueItem,
+ type QueueActionableResult,
+ type QueueActionResult,
+ type ReleasableItem,
+ type RequeueableItem,
+} from "../lib/queue-actions";
export const Route = createFileRoute("/portfolio")({
component: PortfolioPage,
@@ -15,6 +26,12 @@ export const Route = createFileRoute("/portfolio")({
// exactly as `gittensory-miner queue dashboard` already shows -- the miner-ui no longer maintains a narrower,
// global-only aggregation. Same 4-state pattern as the run-history view (loading / error / fresh-install empty
// / populated).
+//
+// The queue-actions section below is a SEPARATE fetch/action loop from the read-only summary above (#4857, the
+// queue half): it lists ONLY the in-flight/completed items an operator can act on and lets them
+// release/requeue via vite-queue-actions-api.ts, mirroring the governor pause/resume control section on the
+// ledgers page. Each row tracks its own pending state (a `Set` of item keys) rather than one global flag, since
+// multiple different rows can plausibly be acted on independently.
const STATUS_LABELS: Record = {
queued: "Queued",
@@ -89,14 +106,169 @@ export function PortfolioQueueView({ result }: { result: PortfolioQueueResult |
);
}
+/** Stable identity for an actionable item across the read snapshot and the pending-action tracking `Set` —
+ * exported so tests can assert on it directly instead of re-deriving the same string inline. */
+export function queueItemKey(item: { apiBaseUrl: string; repoFullName: string; identifier: string }): string {
+ return `${item.apiBaseUrl}|${item.repoFullName}|${item.identifier}`;
+}
+
+export function QueueActionsSection({
+ result,
+ pendingKeys,
+ actionError,
+ onRelease,
+ onRequeue,
+}: {
+ result: QueueActionableResult | null;
+ pendingKeys: Set;
+ actionError: string | null;
+ onRelease: (item: ReleasableItem) => void;
+ onRequeue: (item: RequeueableItem) => void;
+}) {
+ return (
+
+ Queue actions
+ {actionError !== null && (
+
+ Action failed: {actionError}
+
+ )}
+ {result === null ? (
+ Loading actionable queue items…
+ ) : !result.ok ? (
+
+ Could not read actionable queue items: {result.error}
+
+ ) : result.releasable.length === 0 && result.requeueable.length === 0 ? (
+ No in-progress or completed items to act on right now.
+ ) : (
+
+ {result.releasable.length > 0 && (
+
+
In progress (releasable)
+
+
+
+ Repository
+ Identifier
+ Leased
+
+
+
+
+ {result.releasable.map((item) => {
+ const key = queueItemKey(item);
+ return (
+
+ {item.repoFullName}
+ {item.identifier}
+ {item.leasedAt ?? "—"}
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+ {result.requeueable.length > 0 && (
+
+
Done (requeueable)
+
+
+
+ Repository
+ Identifier
+ Enqueued
+
+
+
+
+ {result.requeueable.map((item) => {
+ const key = queueItemKey(item);
+ return (
+
+ {item.repoFullName}
+ {item.identifier}
+ {item.enqueuedAt}
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
export function PortfolioPage({
loadPortfolioQueue = fetchPortfolioQueue,
+ loadQueueActionable = fetchQueueActionable,
+ releaseAction = releaseQueueItem,
+ requeueAction = requeueQueueItem,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
}: {
loadPortfolioQueue?: () => Promise;
+ loadQueueActionable?: () => Promise;
+ releaseAction?: (item: ReleasableItem) => Promise;
+ requeueAction?: (item: RequeueableItem) => Promise;
pollIntervalMs?: number;
}) {
const result = usePolledFetch(loadPortfolioQueue, pollIntervalMs);
+ const [actionableResult, setActionableResult] = useState(null);
+ const [pendingKeys, setPendingKeys] = useState>(new Set());
+ const [actionError, setActionError] = useState(null);
+
+ const refreshActionable = useCallback(() => {
+ void loadQueueActionable().then(setActionableResult);
+ }, [loadQueueActionable]);
+
+ useEffect(() => {
+ refreshActionable();
+ }, [refreshActionable]);
+
+ const runAction = (
+ item: T,
+ action: (item: T) => Promise,
+ ) => {
+ const key = queueItemKey(item);
+ setActionError(null);
+ setPendingKeys((prev) => new Set(prev).add(key));
+ void action(item).then((outcome) => {
+ setPendingKeys((prev) => {
+ const next = new Set(prev);
+ next.delete(key);
+ return next;
+ });
+ if (!outcome.ok) {
+ setActionError(outcome.error);
+ return;
+ }
+ refreshActionable();
+ });
+ };
return (
@@ -107,7 +279,16 @@ export function PortfolioPage({
-
+
+
+
runAction(item, releaseAction)}
+ onRequeue={(item) => runAction(item, requeueAction)}
+ />
+
);
diff --git a/apps/gittensory-miner-ui/vite-queue-actions-api.ts b/apps/gittensory-miner-ui/vite-queue-actions-api.ts
new file mode 100644
index 0000000000..5caa5e7211
--- /dev/null
+++ b/apps/gittensory-miner-ui/vite-queue-actions-api.ts
@@ -0,0 +1,235 @@
+import { existsSync } from "node:fs";
+import type { Plugin } from "vite";
+
+// Portfolio-queue release/requeue action surface for the miner-ui (#4857, the queue half): the governor half
+// (vite-governor-api.ts) already established that /api/* write endpoints are safe once vite-auth.ts (#4858)
+// authenticates every request. This file is the deliberately-deferred "follow-up" that governor-api.ts's own
+// header comment calls out: acting on a specific queue item needs its `repoFullName`/`identifier`, which the
+// read-only portfolio-queue API (vite-portfolio-queue-api.ts) intentionally never republishes -- it only ever
+// serves aggregated status counts, by design, to avoid leaking the queue's rank-derived `priority` ordering.
+//
+// The resolution here is a NARROW, purpose-built read route (`/api/queue/actionable`) that exposes ONLY the two
+// slices an operator actually needs to act on -- in-flight items (releasable) and completed items (requeueable)
+// -- and only the fields needed to identify + act on them (apiBaseUrl/repoFullName/identifier, plus a
+// leasedAt/enqueuedAt timestamp for context). `priority` is stripped from every response on this route, same as
+// the read-only sibling's own rule.
+//
+// Bridges directly to `packages/gittensory-miner/lib/portfolio-queue.js`'s EXISTING store methods
+// (`listInProgress`/`listQueue`/`reclaimStuckItem`/`requeueItem`) -- the SAME functions
+// `gittensory-miner queue release`/`queue requeue` already use (portfolio-queue-cli.js) -- no new queue
+// semantics are invented here.
+
+type QueueEntry = {
+ apiBaseUrl: string;
+ repoFullName: string;
+ identifier: string;
+ status: string;
+ enqueuedAt: string;
+};
+
+type QueueLeaseEntry = {
+ apiBaseUrl: string;
+ repoFullName: string;
+ identifier: string;
+ status: string;
+ leasedAt: string | null;
+};
+
+type PortfolioQueueStore = {
+ listInProgress: () => QueueLeaseEntry[];
+ listQueue: (repoFullName?: string | null) => QueueEntry[];
+ reclaimStuckItem: (repoFullName: string, identifier: string, apiBaseUrl?: string) => QueueEntry | null;
+ requeueItem: (repoFullName: string, identifier: string, apiBaseUrl?: string) => QueueEntry | null;
+ close: () => void;
+};
+
+type PortfolioQueueModule = {
+ resolvePortfolioQueueDbPath: () => string;
+ initPortfolioQueueStore: () => PortfolioQueueStore;
+};
+
+export type QueueActionsApiDeps = {
+ /** Import of `packages/gittensory-miner/lib/portfolio-queue.js` — injectable so tests never touch a real store. */
+ loadPortfolioQueueModule: () => Promise;
+ /** File-existence probe for the fresh-install fast path on both the GET route and the two POST routes: acting
+ * on an item in a store that does not exist yet can never succeed, so this avoids creating the file as a
+ * side effect of a doomed-to-fail action (unlike governor pause/resume, which legitimately DOES create the
+ * store on a fresh install — pausing is a valid first write, but releasing/requeuing a specific item that by
+ * definition cannot exist yet is not). */
+ fileExists: (path: string) => boolean;
+};
+
+const defaultDeps: QueueActionsApiDeps = {
+ loadPortfolioQueueModule: () =>
+ import("../../packages/gittensory-miner/lib/portfolio-queue.js") as Promise,
+ fileExists: existsSync,
+};
+
+type ReleasableItem = { apiBaseUrl: string; repoFullName: string; identifier: string; leasedAt: string | null };
+type RequeueableItem = { apiBaseUrl: string; repoFullName: string; identifier: string; enqueuedAt: string };
+
+type ActionOutcome =
+ { ok: true; entry: Omit & { status: string } } | { ok: false; error: string };
+
+type QueueActionsRoute = "actionable-get" | "release-post" | "requeue-post";
+
+/** Pure route matcher, no I/O — mirrors matchGovernorRoute's shape/contract exactly. */
+export function matchQueueActionsRoute(method: string | undefined, url: string | undefined): QueueActionsRoute | null {
+ if (url === "/api/queue/actionable" && (method === undefined || method === "GET")) return "actionable-get";
+ if (url === "/api/queue/release" && method === "POST") return "release-post";
+ if (url === "/api/queue/requeue" && method === "POST") return "requeue-post";
+ return null;
+}
+
+/** Collects a request body into a string — identical shape to vite-governor-api.ts's own helper. */
+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);
+ });
+}
+
+type ActionTarget = { repoFullName: string; identifier: string; apiBaseUrl: string | undefined };
+
+/** Parses the required `{ repoFullName, identifier, apiBaseUrl? }` POST body. Returns null for an empty/invalid
+ * body or missing required fields — unlike the governor pause route's optional reason, a release/requeue
+ * action is meaningless without knowing WHICH item to act on, so a malformed body here is a real 400, not a
+ * silently-tolerated default. */
+function parseActionTarget(rawBody: string): ActionTarget | null {
+ if (!rawBody.trim()) return null;
+ try {
+ const parsed = JSON.parse(rawBody) as { repoFullName?: unknown; identifier?: unknown; apiBaseUrl?: unknown };
+ if (typeof parsed.repoFullName !== "string" || !parsed.repoFullName.trim()) return null;
+ if (typeof parsed.identifier !== "string" || !parsed.identifier.trim()) return null;
+ const apiBaseUrl =
+ typeof parsed.apiBaseUrl === "string" && parsed.apiBaseUrl.trim() ? parsed.apiBaseUrl : undefined;
+ return { repoFullName: parsed.repoFullName, identifier: parsed.identifier, apiBaseUrl };
+ } catch {
+ return null;
+ }
+}
+
+function stripPriority(entry: QueueEntry): Omit {
+ const { apiBaseUrl, repoFullName, identifier, status, enqueuedAt } = entry;
+ return { apiBaseUrl, repoFullName, identifier, status, enqueuedAt };
+}
+
+/** Executes an ALREADY-MATCHED queue-actions route. Never returns null, mirroring respondToGovernorRoute. */
+async function respondToQueueActionsRoute(
+ route: QueueActionsRoute,
+ rawBody: string,
+ deps: QueueActionsApiDeps,
+): Promise<{ status: number; body: string }> {
+ try {
+ const portfolioQueue = await deps.loadPortfolioQueueModule();
+
+ if (route === "actionable-get") {
+ if (!deps.fileExists(portfolioQueue.resolvePortfolioQueueDbPath())) {
+ return { status: 200, body: JSON.stringify({ releasable: [], requeueable: [] }) };
+ }
+ const store = portfolioQueue.initPortfolioQueueStore();
+ try {
+ const releasable: ReleasableItem[] = store
+ .listInProgress()
+ .map(({ apiBaseUrl, repoFullName, identifier, leasedAt }) => ({
+ apiBaseUrl,
+ repoFullName,
+ identifier,
+ leasedAt,
+ }));
+ const requeueable: RequeueableItem[] = store
+ .listQueue()
+ .filter((entry) => entry.status === "done")
+ .map(({ apiBaseUrl, repoFullName, identifier, enqueuedAt }) => ({
+ apiBaseUrl,
+ repoFullName,
+ identifier,
+ enqueuedAt,
+ }));
+ return { status: 200, body: JSON.stringify({ releasable, requeueable }) };
+ } finally {
+ store.close();
+ }
+ }
+
+ // route is "release-post" or "requeue-post" from here.
+ const target = parseActionTarget(rawBody);
+ if (!target) return { status: 400, body: JSON.stringify({ error: "invalid_request_body" }) };
+
+ if (!deps.fileExists(portfolioQueue.resolvePortfolioQueueDbPath())) {
+ const outcome: ActionOutcome = {
+ ok: false,
+ error: route === "release-post" ? "queue_entry_not_in_progress" : "queue_entry_not_requeuable",
+ };
+ return { status: 200, body: JSON.stringify(outcome) };
+ }
+
+ const store = portfolioQueue.initPortfolioQueueStore();
+ try {
+ const entry =
+ route === "release-post"
+ ? store.reclaimStuckItem(target.repoFullName, target.identifier, target.apiBaseUrl)
+ : store.requeueItem(target.repoFullName, target.identifier, target.apiBaseUrl);
+ const outcome: ActionOutcome = entry
+ ? { ok: true, entry: stripPriority(entry) }
+ : { ok: false, error: route === "release-post" ? "queue_entry_not_in_progress" : "queue_entry_not_requeuable" };
+ return { status: 200, body: JSON.stringify(outcome) };
+ } finally {
+ store.close();
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "failed to update the local portfolio queue";
+ return { status: 500, body: JSON.stringify({ error: message }) };
+ }
+}
+
+/** The request handler, factored out of the Vite plugin shape so tests drive it directly. Returns null when the
+ * request is for none of the three queue-actions routes. */
+export async function handleQueueActionsRequest(
+ method: string | undefined,
+ url: string | undefined,
+ rawBody: string,
+ deps: QueueActionsApiDeps = defaultDeps,
+): Promise<{ status: number; body: string } | null> {
+ const route = matchQueueActionsRoute(method, url);
+ if (!route) return null;
+ return respondToQueueActionsRoute(route, rawBody, deps);
+}
+
+/** Vite dev/preview middleware serving the queue actionable-items read + release/requeue write endpoints. */
+export function queueActionsApiPlugin(deps: QueueActionsApiDeps = 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 = matchQueueActionsRoute(req.method, req.url);
+ if (!route) return next();
+ void readRequestBody(req)
+ .then((rawBody) => respondToQueueActionsRoute(route, rawBody, deps))
+ .then((handled) => {
+ res.statusCode = handled.status;
+ res.setHeader("Content-Type", "application/json");
+ res.end(handled.body);
+ });
+ });
+ };
+ return {
+ name: "gittensory-miner-ui:queue-actions-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 6acd3b18f3..b9f4375f7e 100644
--- a/apps/gittensory-miner-ui/vite.config.ts
+++ b/apps/gittensory-miner-ui/vite.config.ts
@@ -8,6 +8,7 @@ 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 { queueActionsApiPlugin } from "./vite-queue-actions-api";
import { rankedCandidatesApiPlugin } from "./vite-ranked-candidates-api";
import { runStateApiPlugin } from "./vite-run-state-api";
@@ -24,6 +25,7 @@ export default defineConfig({
portfolioQueueApiPlugin(),
ledgersApiPlugin(),
governorApiPlugin(),
+ queueActionsApiPlugin(),
rankedCandidatesApiPlugin(),
],
server: {