diff --git a/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx index fb3e18816d..119ef35b00 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx @@ -1,11 +1,20 @@ import { useState } from "react"; import { Link } from "@tanstack/react-router"; -import { Check, Copy } from "lucide-react"; +import { Check, Copy, Download, History, Loader2, RefreshCw } from "lucide-react"; import { KeyValueGrid, StatusPill, type Status } from "@/components/site/control-primitives"; import { McpVersionBadge } from "@/components/site/mcp-version-badge"; import { StatCard } from "@/components/site/primitives"; import { StateBoundary } from "@/components/site/state-views"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { apiFetch } from "@/lib/api/request"; +import { getApiOrigin } from "@/lib/api/origin"; import { useApiResource } from "@/lib/api/use-api-resource"; import { useSession } from "@/lib/api/session"; import { @@ -100,8 +109,58 @@ export function MinerPanel() { repoFullName: data ? minerCommandRepoCandidate(data) : null, }); + const [refreshing, setRefreshing] = useState(false); + const [refreshNote, setRefreshNote] = useState(null); + const [changelogOpen, setChangelogOpen] = useState(false); + + const refreshPack = async () => { + if (!login || refreshing) return; + setRefreshing(true); + setRefreshNote(null); + const result = await apiFetch<{ status: string }>( + `${getApiOrigin().replace(/\/$/, "")}/v1/app/miner-dashboard/refresh?login=${encodeURIComponent(login)}`, + { method: "POST", label: "Refresh decision pack", credentials: "include" }, + ); + if (result.ok) { + // The rebuild runs as a queued job, so wait briefly then re-fetch the freshly persisted pack. + setRefreshNote("Rebuild queued — refreshing shortly…"); + window.setTimeout(() => { + void dashboard.reload(); + setRefreshing(false); + setRefreshNote(null); + }, 4000); + } else { + setRefreshNote(result.message); + setRefreshing(false); + } + }; + + const exportPack = () => { + if (!data || typeof document === "undefined") return; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `decision-pack-${login || "miner"}-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(url); + }; + + const changelog = data ? collectChangelog(data) : { changes: [], reasons: [] }; + const hasChangelog = changelog.changes.length > 0 || changelog.reasons.length > 0; + return (
+ void refreshPack()} + onExport={exportPack} + onChangelog={() => setChangelogOpen(true)} + /> ) : null} +
); } +type ChangelogEntry = { label: string; change: RecommendationChange }; +type Changelog = { changes: ChangelogEntry[]; reasons: RerunReasonGroup[] }; + +// Aggregate the per-recommendation old-vs-new diffs and re-run reasons the API already attaches to each +// next-action / repo-fit row into one changelog view. The inline cards truncate (2 reasons); the modal shows +// everything. `unchanged` rows are skipped from the diff list — only new/changed recommendations are news. +function collectChangelog(data: MinerDashboard): Changelog { + const changes: ChangelogEntry[] = []; + const reasonsByGroup = new Map(); + const ingest = ( + label: string, + change?: RecommendationChange, + rerunReasons?: RerunReasonGroup[], + ) => { + if (change && change.status !== "unchanged") changes.push({ label, change }); + for (const group of rerunReasons ?? []) { + if (group.reasons.length === 0) continue; + const existing = reasonsByGroup.get(group.group); + if (existing) { + existing.reasons = [...new Set([...existing.reasons, ...group.reasons])]; + } else { + reasonsByGroup.set(group.group, { ...group, reasons: [...group.reasons] }); + } + } + }; + for (const action of data.nextActions) { + ingest(stringField(action, "actionKind", "Next action"), action.change, action.rerunReasons); + } + for (const repo of data.repoFit) { + ingest(stringField(repo, "repoFullName", "Repo"), repo.change, repo.rerunReasons); + } + return { changes, reasons: [...reasonsByGroup.values()] }; +} + +function MinerPanelActions({ + canRefresh, + canExport, + hasChangelog, + refreshing, + refreshNote, + onRefresh, + onExport, + onChangelog, +}: { + canRefresh: boolean; + canExport: boolean; + hasChangelog: boolean; + refreshing: boolean; + refreshNote: string | null; + onRefresh: () => void; + onExport: () => void; + onChangelog: () => void; +}) { + const buttonClass = + "inline-flex items-center gap-2 rounded-token border-hairline bg-card px-3 py-2 text-token-xs font-medium text-foreground transition-colors hover:border-strong disabled:cursor-not-allowed disabled:opacity-50"; + return ( +
+
+

Decision pack

+
+ Rebuild from the web, review what changed, or export the pack. +
+
+
+ + + + + {refreshNote ?? ""} + +
+
+ ); +} + +function ChangelogDialog({ + open, + onOpenChange, + changelog, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + changelog: Changelog; +}) { + return ( + + + + Recommendation changelog + + What changed since the previous decision pack, and why it re-ran. Deterministic signals + only — no payout or reward estimates. + + + +
+
+

+ Old vs new ({changelog.changes.length}) +

+ {changelog.changes.length === 0 ? ( +

+ No recommendations changed since the last pack. +

+ ) : ( +
    + {changelog.changes.map((entry, index) => ( +
  • +
    + + {entry.change.status} + + + {entry.label} + +
    +

    + {entry.change.summary} +

    + {entry.change.labels.length > 0 && ( +
    + {entry.change.labels.map((label) => ( +
    +
    + {label.label} +
    +
    + {label.before ? `${label.before} -> ` : ""} + {label.after ?? "changed"} +
    +
    + ))} +
    + )} +
  • + ))} +
+ )} +
+ + {changelog.reasons.length > 0 && ( +
+

+ Why it re-ran +

+
+ {changelog.reasons.map((group) => ( +
+
+ {group.title} +
+
    + {group.reasons.map((reason) => ( +
  • + {reason} +
  • + ))} +
+
+ ))} +
+
+ )} +
+
+
+ ); +} + const COMMAND_STATE_TONE: Record = { setup: "info", ready: "ready", diff --git a/src/api/routes.ts b/src/api/routes.ts index 2f275487dd..c69bf35cad 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1049,6 +1049,22 @@ export function createApp() { }); }); + // #129 in-UI "refresh decision pack" — enqueues the same contributor decision-pack rebuild the MCP job + // runs, so a miner can refresh from the web app instead of running MCP locally. Contributor-authed + // (same gate as the dashboard read); the rebuild is async, so the panel re-fetches after it lands. + app.post("/v1/app/miner-dashboard/refresh", async (c) => { + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- the write-protection middleware rejects unauthenticated POSTs before this handler. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + const login = c.req.query("login") ?? (identity.kind === "session" ? identity.actor : ""); + if (!login) return c.json({ error: "login_required" }, 400); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const message: JobMessage = { type: "build-contributor-decision-packs", requestedBy: "api", login }; + await c.env.JOBS.send(message); + return c.json({ status: "queued", login }, 202); + }); + app.get("/v1/app/maintainer-dashboard", async (c) => { const identity = await authenticateRequestIdentity(c); if (!identity) return c.json({ error: "unauthorized" }, 401); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index ba78020c51..4a9e86750c 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -2043,6 +2043,22 @@ describe("api routes", () => { expect(forbiddenMiner.status).toBe(403); expect((await app.request("/v1/app/maintainer-dashboard", {}, env)).status).toBe(401); + // #129 in-UI "refresh decision pack": contributor-authed enqueue of the decision-pack rebuild. + const refreshMissingLogin = await app.request("/v1/app/miner-dashboard/refresh", { method: "POST", headers: apiHeaders(env) }, env); + expect(refreshMissingLogin.status).toBe(400); + const refreshForbidden = await app.request("/v1/app/miner-dashboard/refresh?login=oktofeesh1", { method: "POST", headers: { cookie: `gittensory_session=${otherToken}` } }, env); + expect(refreshForbidden.status).toBe(403); + const refreshQueued = await app.request("/v1/app/miner-dashboard/refresh?login=oktofeesh1", { method: "POST", headers: apiHeaders(env) }, env); + expect(refreshQueued.status).toBe(202); + await expect(refreshQueued.json()).resolves.toMatchObject({ status: "queued", login: "oktofeesh1" }); + // No ?login → the login resolves from the session actor (covers the session-actor fallback). + const refreshSelf = await app.request("/v1/app/miner-dashboard/refresh", { method: "POST", headers: { cookie: `gittensory_session=${otherToken}` } }, env); + expect(refreshSelf.status).toBe(202); + await expect(refreshSelf.json()).resolves.toMatchObject({ status: "queued", login: "other" }); + // Unauthenticated POST is rejected by the write-protection middleware before the handler. + const refreshUnauth = await app.request("/v1/app/miner-dashboard/refresh", { method: "POST" }, env); + expect(refreshUnauth.status).toBe(401); + const unknownEnv = createTestEnv({ ADMIN_GITHUB_LOGINS: "jsonbored" }); const { token: unknownToken } = await createSessionForGitHubUser(unknownEnv, { login: "new-user", id: 2468 }); const unknownHeaders = { cookie: `gittensory_session=${unknownToken}`, "content-type": "application/json" };