diff --git a/apps/gittensory-miner-ui/src/lib/portfolio-queue-actions.ts b/apps/gittensory-miner-ui/src/lib/portfolio-queue-actions.ts new file mode 100644 index 0000000000..5680f5edd9 --- /dev/null +++ b/apps/gittensory-miner-ui/src/lib/portfolio-queue-actions.ts @@ -0,0 +1,122 @@ +// Client for the local portfolio-queue release/requeue write API (#4857, the queue half of "Add real actions to +// the miner-ui"). Mirrors the CLI's `gittensory-miner queue release` / `queue requeue` commands via the +// authenticated dev-server bridge in vite-portfolio-queue-actions-api.ts. + +export const PORTFOLIO_QUEUE_ITEMS_API_PATH = "/api/portfolio-queue/items"; +export const PORTFOLIO_QUEUE_RELEASE_API_PATH = "/api/portfolio-queue/release"; +export const PORTFOLIO_QUEUE_REQUEUE_API_PATH = "/api/portfolio-queue/requeue"; + +export type PortfolioQueueActionItem = { + apiBaseUrl: string; + repoFullName: string; + identifier: string; + status: "in_progress" | "done"; +}; + +export type PortfolioQueueItemsResult = { ok: true; items: PortfolioQueueActionItem[] } | { ok: false; error: string }; + +export type PortfolioQueueActionResult = + { ok: true; entry: { repoFullName: string; identifier: string; status: string } } | { ok: false; error: string }; + +function isPortfolioQueueActionItem(value: unknown): value is PortfolioQueueActionItem { + 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.status === "in_progress" || item.status === "done") + ); +} + +function parseItemsResponse(response: Response, label: string): Promise { + if (!response.ok) return Promise.resolve({ ok: false, error: `${label} responded ${response.status}` }); + return response.json().then((payload: unknown) => { + const items = (payload as { items?: unknown }).items; + if (!Array.isArray(items) || !items.every(isPortfolioQueueActionItem)) { + return { ok: false, error: `${label} returned an unexpected payload shape` }; + } + return { ok: true, items }; + }); +} + +function parseActionResponse(response: Response, label: string): Promise { + if (!response.ok) { + return response + .json() + .catch(() => ({})) + .then((payload: unknown) => { + const error = (payload as { error?: unknown }).error; + if (typeof error === "string" && error) { + return { ok: false, error }; + } + return { ok: false, error: `${label} responded ${response.status}` }; + }); + } + return response.json().then((payload: unknown) => { + const entry = (payload as { entry?: unknown }).entry; + if ( + typeof entry !== "object" || + entry === null || + typeof (entry as { repoFullName?: unknown }).repoFullName !== "string" || + typeof (entry as { identifier?: unknown }).identifier !== "string" || + typeof (entry as { status?: unknown }).status !== "string" + ) { + return { ok: false, error: `${label} returned an unexpected payload shape` }; + } + return { ok: true, entry: entry as { repoFullName: string; identifier: string; status: string } }; + }); +} + +/** Fetch actionable queue rows (in_progress + done) for release/requeue controls. */ +export async function fetchPortfolioQueueItems(fetchImpl: typeof fetch = fetch): Promise { + try { + const response = await fetchImpl(PORTFOLIO_QUEUE_ITEMS_API_PATH); + return await parseItemsResponse(response, "local portfolio-queue items API"); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local portfolio-queue items API", + }; + } +} + +/** Release a claimed (in_progress) item back to queued — mirrors `gittensory-miner queue release`. */ +export function releasePortfolioQueueItem( + item: Pick, + fetchImpl: typeof fetch = fetch, +): Promise { + return postPortfolioQueueAction(PORTFOLIO_QUEUE_RELEASE_API_PATH, item, fetchImpl); +} + +/** Requeue a completed (done) item — mirrors `gittensory-miner queue requeue`. */ +export function requeuePortfolioQueueItem( + item: Pick, + fetchImpl: typeof fetch = fetch, +): Promise { + return postPortfolioQueueAction(PORTFOLIO_QUEUE_REQUEUE_API_PATH, item, fetchImpl); +} + +async function postPortfolioQueueAction( + path: string, + item: Pick, + fetchImpl: typeof fetch, +): Promise { + try { + const response = await fetchImpl(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repoFullName: item.repoFullName, + identifier: item.identifier, + apiBaseUrl: item.apiBaseUrl, + }), + }); + return await parseActionResponse(response, "local portfolio-queue action API"); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local portfolio-queue action API", + }; + } +} diff --git a/apps/gittensory-miner-ui/src/portfolio-queue-actions.test.tsx b/apps/gittensory-miner-ui/src/portfolio-queue-actions.test.tsx new file mode 100644 index 0000000000..bc2740d1b5 --- /dev/null +++ b/apps/gittensory-miner-ui/src/portfolio-queue-actions.test.tsx @@ -0,0 +1,369 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + fetchPortfolioQueueItems, + PORTFOLIO_QUEUE_ITEMS_API_PATH, + PORTFOLIO_QUEUE_RELEASE_API_PATH, + PORTFOLIO_QUEUE_REQUEUE_API_PATH, + releasePortfolioQueueItem, + requeuePortfolioQueueItem, + type PortfolioQueueActionItem, +} from "./lib/portfolio-queue-actions"; +import { PortfolioPage, PortfolioQueueActionsSection } from "./routes/portfolio"; +import type { PortfolioQueueResult } from "./lib/portfolio-queue"; +import { + handlePortfolioQueueActionsRequest, + matchPortfolioQueueActionRoute, + portfolioQueueActionsApiPlugin, + type PortfolioQueueActionsApiDeps, +} from "../vite-portfolio-queue-actions-api"; + +const fixtureSummary = { + total: 2, + byStatus: { queued: 0, in_progress: 1, done: 1 }, + repos: [{ repoFullName: "acme/widgets", byStatus: { queued: 0, in_progress: 1, done: 1 }, total: 2 }], + oldestQueuedAgeMs: null, +}; + +const inProgressItem: PortfolioQueueActionItem = { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:12", + status: "in_progress", +}; + +const doneItem: PortfolioQueueActionItem = { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:7", + status: "done", +}; + +describe("PortfolioQueueActionsSection (#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("shows Release for in_progress rows and Requeue for done rows", () => { + const onRelease = vi.fn(); + const onRequeue = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Release" })); + fireEvent.click(screen.getByRole("button", { name: "Requeue" })); + expect(onRelease).toHaveBeenCalledWith(inProgressItem); + expect(onRequeue).toHaveBeenCalledWith(doneItem); + }); + + it("disables action buttons while an action is pending", () => { + render( + undefined} + onRequeue={() => undefined} + />, + ); + expect((screen.getByRole("button", { name: "Release" }) as HTMLButtonElement).disabled).toBe(true); + }); +}); + +describe("PortfolioPage queue actions (#4857)", () => { + const loadPortfolioQueue = async (): Promise => ({ ok: true, summary: fixtureSummary }); + const loadPortfolioQueueItems = async () => ({ ok: true as const, items: [inProgressItem] }); + + it("loads actionable items and wires release through the injected action", async () => { + const releaseItem = vi.fn(async () => ({ + ok: true as const, + entry: { repoFullName: "acme/widgets", identifier: "issue:12", status: "queued" }, + })); + render( + , + ); + await waitFor(() => expect(screen.getByRole("button", { name: "Release" })).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: "Release" })); + await waitFor(() => expect(releaseItem).toHaveBeenCalledWith(inProgressItem)); + }); +}); + +describe("fetchPortfolioQueueItems / release / requeue (#4857)", () => { + const jsonResponse = (status: number, payload: unknown) => + ({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response; + + it("fetchPortfolioQueueItems returns typed items from a well-formed payload", async () => { + let requested: string | undefined; + const result = await fetchPortfolioQueueItems(async (input) => { + requested = String(input); + return jsonResponse(200, { items: [inProgressItem] }); + }); + expect(requested).toBe(PORTFOLIO_QUEUE_ITEMS_API_PATH); + expect(result).toEqual({ ok: true, items: [inProgressItem] }); + }); + + it("releasePortfolioQueueItem POSTs to the release path with the item body", async () => { + let requested: string | undefined; + let init: RequestInit | undefined; + const result = await releasePortfolioQueueItem(inProgressItem, async (input, options) => { + requested = String(input); + init = options; + return jsonResponse(200, { entry: { repoFullName: "acme/widgets", identifier: "issue:12", status: "queued" } }); + }); + expect(requested).toBe(PORTFOLIO_QUEUE_RELEASE_API_PATH); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + repoFullName: inProgressItem.repoFullName, + identifier: inProgressItem.identifier, + apiBaseUrl: inProgressItem.apiBaseUrl, + }); + expect(result.ok).toBe(true); + }); + + it("requeuePortfolioQueueItem POSTs to the requeue path", async () => { + let requested: string | undefined; + await requeuePortfolioQueueItem(doneItem, async (input) => { + requested = String(input); + return jsonResponse(200, { entry: { repoFullName: "acme/widgets", identifier: "issue:7", status: "queued" } }); + }); + expect(requested).toBe(PORTFOLIO_QUEUE_REQUEUE_API_PATH); + }); + + it("surfaces API error codes from non-2xx action responses", async () => { + expect( + await releasePortfolioQueueItem(inProgressItem, async () => + jsonResponse(409, { error: "queue_entry_not_in_progress" }), + ), + ).toEqual({ ok: false, error: "queue_entry_not_in_progress" }); + }); +}); + +describe("matchPortfolioQueueActionRoute / handlePortfolioQueueActionsRequest (#4857)", () => { + function deps(overrides: Partial = {}): PortfolioQueueActionsApiDeps { + const entries = [ + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:12", + status: "in_progress", + priority: 1, + enqueuedAt: "2026-07-10T06:00:00.000Z", + }, + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:7", + status: "done", + priority: 1, + enqueuedAt: "2026-07-10T05:00:00.000Z", + }, + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:9", + status: "queued", + priority: 1, + enqueuedAt: "2026-07-10T04:00:00.000Z", + }, + ]; + return { + loadPortfolioQueueModule: async () => ({ + resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3", + initPortfolioQueueStore: () => ({ + listQueue: () => entries, + reclaimStuckItem: (repoFullName, identifier) => { + const match = entries.find( + (entry) => + entry.repoFullName === repoFullName && + entry.identifier === identifier && + entry.status === "in_progress", + ); + if (!match) return null; + match.status = "queued"; + return { ...match }; + }, + requeueItem: (repoFullName, identifier) => { + const match = entries.find( + (entry) => + entry.repoFullName === repoFullName && entry.identifier === identifier && entry.status === "done", + ); + if (!match) return null; + match.status = "queued"; + return { ...match }; + }, + close: () => undefined, + }), + }), + fileExists: () => true, + ...overrides, + }; + } + + it("matches the three portfolio-queue action routes", () => { + expect(matchPortfolioQueueActionRoute("GET", "/api/portfolio-queue/items")).toBe("items-get"); + expect(matchPortfolioQueueActionRoute("POST", "/api/portfolio-queue/release")).toBe("release-post"); + expect(matchPortfolioQueueActionRoute("POST", "/api/portfolio-queue/requeue")).toBe("requeue-post"); + expect(matchPortfolioQueueActionRoute("GET", "/api/portfolio-queue/release")).toBeNull(); + }); + + it("GET items returns only in_progress and done rows", async () => { + const handled = await handlePortfolioQueueActionsRequest("GET", "/api/portfolio-queue/items", "", deps()); + expect(handled?.status).toBe(200); + const body = JSON.parse(handled?.body ?? "{}") as { items: PortfolioQueueActionItem[] }; + expect(body.items).toEqual([ + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:12", + status: "in_progress", + }, + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:7", + status: "done", + }, + ]); + }); + + it("GET items serves an empty list on a fresh install without opening the store", async () => { + let opened = false; + const handled = await handlePortfolioQueueActionsRequest( + "GET", + "/api/portfolio-queue/items", + "", + deps({ + fileExists: () => false, + loadPortfolioQueueModule: async () => ({ + resolvePortfolioQueueDbPath: () => "/nowhere/portfolio-queue.sqlite3", + initPortfolioQueueStore: () => { + opened = true; + throw new Error("should not open store"); + }, + }), + }), + ); + expect(opened).toBe(false); + expect(JSON.parse(handled?.body ?? "{}")).toEqual({ items: [] }); + }); + + it("POST release reclaims an in_progress item and POST requeue revives a done item", async () => { + const release = await handlePortfolioQueueActionsRequest( + "POST", + "/api/portfolio-queue/release", + JSON.stringify({ repoFullName: "acme/widgets", identifier: "issue:12" }), + deps(), + ); + expect(release?.status).toBe(200); + expect(JSON.parse(release?.body ?? "{}")).toEqual({ + entry: { repoFullName: "acme/widgets", identifier: "issue:12", status: "queued" }, + }); + + const requeue = await handlePortfolioQueueActionsRequest( + "POST", + "/api/portfolio-queue/requeue", + JSON.stringify({ repoFullName: "acme/widgets", identifier: "issue:7" }), + deps(), + ); + expect(requeue?.status).toBe(200); + expect(JSON.parse(requeue?.body ?? "{}")).toEqual({ + entry: { repoFullName: "acme/widgets", identifier: "issue:7", status: "queued" }, + }); + }); + + it("POST release returns 409 when the item is not in_progress", async () => { + const handled = await handlePortfolioQueueActionsRequest( + "POST", + "/api/portfolio-queue/release", + JSON.stringify({ repoFullName: "acme/widgets", identifier: "issue:7" }), + deps(), + ); + expect(handled).toEqual({ status: 409, body: JSON.stringify({ error: "queue_entry_not_in_progress" }) }); + }); + + it("POST requeue returns 409 when the item is not requeuable", async () => { + const handled = await handlePortfolioQueueActionsRequest( + "POST", + "/api/portfolio-queue/requeue", + JSON.stringify({ repoFullName: "acme/widgets", identifier: "issue:12" }), + deps(), + ); + expect(handled).toEqual({ status: 409, body: JSON.stringify({ error: "queue_entry_not_requeuable" }) }); + }); + + it("returns 400 for a malformed POST body", async () => { + const handled = await handlePortfolioQueueActionsRequest("POST", "/api/portfolio-queue/release", "{bad", deps()); + expect(handled?.status).toBe(400); + }); +}); + +describe("portfolioQueueActionsApiPlugin (#4857)", () => { + it("registers middleware that serves GET /api/portfolio-queue/items", async () => { + type CapturedRequestHandler = ( + req: { method?: string; url?: string }, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, + ) => void; + let captured: CapturedRequestHandler | undefined; + const plugin = portfolioQueueActionsApiPlugin({ + loadPortfolioQueueModule: async () => ({ + resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3", + initPortfolioQueueStore: () => ({ + listQueue: () => [inProgressItem], + reclaimStuckItem: () => null, + requeueItem: () => null, + close: () => undefined, + }), + }), + fileExists: () => true, + }); + 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("plugin did not register middleware"); + let ended: string | undefined; + captured( + { method: "GET", url: "/api/portfolio-queue/items" }, + { + statusCode: 0, + setHeader: () => undefined, + end(body: string) { + ended = body; + }, + }, + () => undefined, + ); + await vi.waitFor(() => expect(ended).toBeTruthy()); + expect(JSON.parse(ended ?? "{}")).toEqual({ items: [inProgressItem] }); + }); +}); diff --git a/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx index c84864fb47..2fa5461591 100644 --- a/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx +++ b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx @@ -99,9 +99,11 @@ describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => { }); describe("PortfolioPage (#4306)", () => { + const loadPortfolioQueueItems = async () => ({ ok: true as const, items: [] }); + it("loads the summary through the injected loader and renders the cards", async () => { const loadPortfolioQueue = async (): Promise => ({ ok: true, summary: fixtureSummary }); - render(); + render(); expect(screen.getByRole("heading", { name: "Portfolio queue" })).toBeTruthy(); await waitFor(() => expect(screen.getByText("Queued", { selector: "dt" }).nextSibling?.textContent).toBe("2")); }); @@ -117,7 +119,13 @@ describe("PortfolioPage (#4306)", () => { ok: true, summary: fixtureSummary, })); - render(); + render( + , + ); await vi.waitFor(() => expect(loadPortfolioQueue).toHaveBeenCalledTimes(1)); await vi.advanceTimersByTimeAsync(1000); diff --git a/apps/gittensory-miner-ui/src/routes/portfolio.tsx b/apps/gittensory-miner-ui/src/routes/portfolio.tsx index ee106dfc95..685535c711 100644 --- a/apps/gittensory-miner-ui/src/routes/portfolio.tsx +++ b/apps/gittensory-miner-ui/src/routes/portfolio.tsx @@ -1,8 +1,16 @@ 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 { + fetchPortfolioQueueItems, + requeuePortfolioQueueItem, + releasePortfolioQueueItem, +} from "../lib/portfolio-queue-actions"; +import type { PortfolioQueueActionItem, PortfolioQueueItemsResult } from "../lib/portfolio-queue-actions"; import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch"; import { fetchPortfolioQueue, type PortfolioQueueResult, type QueueStatus } from "../lib/portfolio-queue"; @@ -11,10 +19,7 @@ export const Route = createFileRoute("/portfolio")({ }); // Portfolio/queue summary cards + per-repo table (#4306, reunified with the CLI's own richer `queue dashboard` -// by #4846): read-only counts by status over the local `miner_portfolio_queue` store, now broken out per repo -// 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). +// by #4846), plus release/requeue controls (#4857) backed by the same store methods the CLI uses. const STATUS_LABELS: Record = { queued: "Queued", @@ -22,8 +27,6 @@ const STATUS_LABELS: Record = { done: "Done", }; -// Semantic tone per status, sourced from the shared design system's success/warning -// tokens rather than arbitrary color utilities — kept separate from the accent hue. const STATUS_TONE: Record = { queued: "text-muted-foreground", in_progress: "text-[var(--warning)]", @@ -89,25 +92,121 @@ export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | ); } +export function PortfolioQueueActionsSection({ + result, + pending, + onRelease, + onRequeue, +}: { + result: PortfolioQueueItemsResult | null; + pending: boolean; + onRelease: (item: PortfolioQueueActionItem) => void; + onRequeue: (item: PortfolioQueueActionItem) => void; +}) { + return ( +
+

Queue actions

+ {result === null ? ( +

Loading actionable queue items…

+ ) : !result.ok ? ( +

+ Could not read actionable queue items: {result.error} +

+ ) : result.items.length === 0 ? ( +

+ No in-progress or completed items to release or requeue right now. +

+ ) : ( + + + + Repository + Identifier + Status + Action + + + + {result.items.map((item) => ( + + {item.repoFullName} + {item.identifier} + {STATUS_LABELS[item.status]} + + {item.status === "in_progress" ? ( + + ) : ( + + )} + + + ))} + +
+ )} +
+ ); +} + export function PortfolioPage({ loadPortfolioQueue = fetchPortfolioQueue, + loadPortfolioQueueItems = fetchPortfolioQueueItems, + releaseItem = releasePortfolioQueueItem, + requeueItem = requeuePortfolioQueueItem, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, }: { loadPortfolioQueue?: () => Promise; + loadPortfolioQueueItems?: () => Promise; + releaseItem?: typeof releasePortfolioQueueItem; + requeueItem?: typeof requeuePortfolioQueueItem; pollIntervalMs?: number; }) { - const result = usePolledFetch(loadPortfolioQueue, pollIntervalMs); + const [refreshKey, setRefreshKey] = useState(0); + const [actionPending, setActionPending] = useState(false); + const [itemsResult, setItemsResult] = useState(null); + + const loadSummary = useCallback(() => loadPortfolioQueue(), [loadPortfolioQueue, refreshKey]); + const summaryResult = usePolledFetch(loadSummary, pollIntervalMs); + + const refreshItems = useCallback(() => { + void loadPortfolioQueueItems().then(setItemsResult); + }, [loadPortfolioQueueItems, refreshKey]); + + useEffect(() => { + refreshItems(); + }, [refreshItems]); + + const runQueueAction = (action: () => Promise) => { + setActionPending(true); + void action().then(() => { + setRefreshKey((key) => key + 1); + refreshItems(); + setActionPending(false); + }); + }; return (

Portfolio queue

- Local, read-only summary of the miner's portfolio queue (`miner_portfolio_queue`). + Local summary and controls for the miner's portfolio queue (`miner_portfolio_queue`).

- +
+ + runQueueAction(() => releaseItem(item))} + onRequeue={(item) => runQueueAction(() => requeueItem(item))} + /> +
); diff --git a/apps/gittensory-miner-ui/vite-governor-api.ts b/apps/gittensory-miner-ui/vite-governor-api.ts index a40fb05312..400824b32e 100644 --- a/apps/gittensory-miner-ui/vite-governor-api.ts +++ b/apps/gittensory-miner-ui/vite-governor-api.ts @@ -10,10 +10,7 @@ import type { Plugin } from "vite"; // invented here, and this file never touches governor-chokepoint.js/governor-chokepoint-persisted.js (the // governor's actual decision-to-proceed logic stays untouched, per #4857's own scope note). // -// Queue release/requeue actions (the other half of #4857) are deliberately NOT included here: they need a -// per-item `identifier`, which the read-only portfolio-queue API intentionally never republishes over the wire -// (see vite-portfolio-queue-api.ts's own header comment) -- exposing identifiers safely is a separate design -// question, not a trivial wire-up, and is left for a follow-up. +// Queue release/requeue actions live in vite-portfolio-queue-actions-api.ts (#4857, the queue half). // // Same read-only-safe fresh-install rule as the sibling GET endpoints for the READ route only: `loadPauseState()` // lazily initializes the default store, which would CREATE the SQLite file (a write) on a fresh install -- so diff --git a/apps/gittensory-miner-ui/vite-portfolio-queue-actions-api.ts b/apps/gittensory-miner-ui/vite-portfolio-queue-actions-api.ts new file mode 100644 index 0000000000..ea0ba972b3 --- /dev/null +++ b/apps/gittensory-miner-ui/vite-portfolio-queue-actions-api.ts @@ -0,0 +1,216 @@ +import { existsSync } from "node:fs"; +import type { Plugin } from "vite"; + +// Portfolio-queue release/requeue control surface for the miner-ui (#4857, the queue half): a thin bridge to the +// EXISTING store methods the CLI's `queue release` / `queue requeue` subcommands already use +// (portfolio-queue-cli.js → reclaimStuckItem / requeueItem) — no new queue semantics are invented here. +// +// Unlike the read-only dashboard GET in vite-portfolio-queue-api.ts, these routes intentionally republish each +// item's `identifier` (plus repo + forge host) to the authenticated local UI so an operator can act on a +// specific row. That exposure is acceptable here because vite-auth.ts (#4858) already gates every /api/* +// request behind a same-origin HttpOnly session cookie — the identifiers never cross an unauthenticated wire. +// +// GET `/api/portfolio-queue/items` follows the sibling fresh-install rule: if the resolved DB file does not +// exist yet, serve an empty list without opening the store (which would CREATE the file). The two POST routes +// have no such fast path — mutating on a fresh install is expected to create the store, like the CLI. + +type QueueEntry = { + apiBaseUrl: string; + repoFullName: string; + identifier: string; + status: string; +}; + +type PortfolioQueueModule = { + resolvePortfolioQueueDbPath: () => string; + initPortfolioQueueStore: () => { + listQueue: (repoFullName?: string | null) => QueueEntry[]; + reclaimStuckItem: (repoFullName: string, identifier: string, apiBaseUrl?: string | null) => QueueEntry | null; + requeueItem: (repoFullName: string, identifier: string, apiBaseUrl?: string | null) => QueueEntry | null; + close: () => void; + }; +}; + +export type PortfolioQueueActionItem = { + apiBaseUrl: string; + repoFullName: string; + identifier: string; + status: "in_progress" | "done"; +}; + +export type PortfolioQueueActionsApiDeps = { + loadPortfolioQueueModule: () => Promise; + fileExists: (path: string) => boolean; +}; + +const defaultDeps: PortfolioQueueActionsApiDeps = { + loadPortfolioQueueModule: () => + import("../../packages/gittensory-miner/lib/portfolio-queue.js") as Promise, + fileExists: existsSync, +}; + +function emptyItemsResponse(): { status: number; body: string } { + return { status: 200, body: JSON.stringify({ items: [] as PortfolioQueueActionItem[] }) }; +} + +function toActionItem(entry: QueueEntry): PortfolioQueueActionItem | null { + if (entry.status !== "in_progress" && entry.status !== "done") return null; + return { + apiBaseUrl: entry.apiBaseUrl, + repoFullName: entry.repoFullName, + identifier: entry.identifier, + status: entry.status, + }; +} + +function parseActionBody(rawBody: string): { repoFullName: string; identifier: string; apiBaseUrl?: string } | null { + if (!rawBody.trim()) return null; + try { + const parsed: unknown = JSON.parse(rawBody); + const record = parsed as { repoFullName?: unknown; identifier?: unknown; apiBaseUrl?: unknown }; + if (typeof record.repoFullName !== "string" || typeof record.identifier !== "string") return null; + const repoFullName = record.repoFullName.trim(); + const identifier = record.identifier.trim(); + if (!repoFullName || !identifier) return null; + const body: { repoFullName: string; identifier: string; apiBaseUrl?: string } = { repoFullName, identifier }; + if (typeof record.apiBaseUrl === "string" && record.apiBaseUrl.trim()) { + body.apiBaseUrl = record.apiBaseUrl.trim(); + } + return body; + } catch { + return null; + } +} + +export type PortfolioQueueActionRoute = "items-get" | "release-post" | "requeue-post"; + +/** Pure route matcher — safe to call synchronously before reading a request body. */ +export function matchPortfolioQueueActionRoute( + method: string | undefined, + url: string | undefined, +): PortfolioQueueActionRoute | null { + if (url === "/api/portfolio-queue/items" && (method === undefined || method === "GET")) return "items-get"; + if (url === "/api/portfolio-queue/release" && method === "POST") return "release-post"; + if (url === "/api/portfolio-queue/requeue" && method === "POST") return "requeue-post"; + return null; +} + +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); + }); +} + +async function respondToPortfolioQueueActionRoute( + route: PortfolioQueueActionRoute, + rawBody: string, + deps: PortfolioQueueActionsApiDeps, +): Promise<{ status: number; body: string }> { + try { + const queueModule = await deps.loadPortfolioQueueModule(); + if (route === "items-get") { + if (!deps.fileExists(queueModule.resolvePortfolioQueueDbPath())) { + return emptyItemsResponse(); + } + const store = queueModule.initPortfolioQueueStore(); + try { + const items = store + .listQueue() + .map(toActionItem) + .filter((item): item is PortfolioQueueActionItem => item !== null); + return { status: 200, body: JSON.stringify({ items }) }; + } finally { + store.close(); + } + } + + const parsed = parseActionBody(rawBody); + if (!parsed) { + return { status: 400, body: JSON.stringify({ error: "invalid_request_body" }) }; + } + + const store = queueModule.initPortfolioQueueStore(); + try { + if (route === "release-post") { + const entry = store.reclaimStuckItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl ?? null); + if (!entry) { + return { status: 409, body: JSON.stringify({ error: "queue_entry_not_in_progress" }) }; + } + return { + status: 200, + body: JSON.stringify({ + entry: { repoFullName: entry.repoFullName, identifier: entry.identifier, status: entry.status }, + }), + }; + } + const entry = store.requeueItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl ?? null); + if (!entry) { + return { status: 409, body: JSON.stringify({ error: "queue_entry_not_requeuable" }) }; + } + return { + status: 200, + body: JSON.stringify({ + entry: { repoFullName: entry.repoFullName, identifier: entry.identifier, status: entry.status }, + }), + }; + } 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 }) }; + } +} + +/** Request handler factored out for direct unit tests (mirrors vite-governor-api.ts). */ +export async function handlePortfolioQueueActionsRequest( + method: string | undefined, + url: string | undefined, + rawBody: string, + deps: PortfolioQueueActionsApiDeps = defaultDeps, +): Promise<{ status: number; body: string } | null> { + const route = matchPortfolioQueueActionRoute(method, url); + if (!route) return null; + return respondToPortfolioQueueActionRoute(route, rawBody, deps); +} + +/** Vite dev/preview middleware for portfolio-queue item listing + release/requeue write endpoints. */ +export function portfolioQueueActionsApiPlugin(deps: PortfolioQueueActionsApiDeps = 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 = matchPortfolioQueueActionRoute(req.method, req.url); + if (!route) return next(); + const run = + route === "items-get" + ? respondToPortfolioQueueActionRoute(route, "", deps) + : readRequestBody(req).then((rawBody) => respondToPortfolioQueueActionRoute(route, rawBody, deps)); + void Promise.resolve(run).then((handled) => { + res.statusCode = handled.status; + res.setHeader("Content-Type", "application/json"); + res.end(handled.body); + }); + }); + }; + return { + name: "gittensory-miner-ui:portfolio-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..c74f04f7f3 100644 --- a/apps/gittensory-miner-ui/vite.config.ts +++ b/apps/gittensory-miner-ui/vite.config.ts @@ -7,6 +7,7 @@ import tsconfigPaths from "vite-tsconfig-paths"; import { authPlugin } from "./vite-auth"; import { governorApiPlugin } from "./vite-governor-api"; import { ledgersApiPlugin } from "./vite-ledgers-api"; +import { portfolioQueueActionsApiPlugin } from "./vite-portfolio-queue-actions-api"; import { portfolioQueueApiPlugin } from "./vite-portfolio-queue-api"; import { rankedCandidatesApiPlugin } from "./vite-ranked-candidates-api"; import { runStateApiPlugin } from "./vite-run-state-api"; @@ -22,6 +23,7 @@ export default defineConfig({ authPlugin(), runStateApiPlugin(), portfolioQueueApiPlugin(), + portfolioQueueActionsApiPlugin(), ledgersApiPlugin(), governorApiPlugin(), rankedCandidatesApiPlugin(),