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: {