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 e9644a15..6adc89da 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 @@ -14,10 +14,44 @@ const LANE_TONE: Record = { avoid: "blocked", }; +const CHANGE_TONE: Record = { + new: "info", + changed: "warn", + unchanged: "ready", +}; + +type RecommendationSignalGroup = + | "repo_state" + | "contributor_state" + | "validation_state" + | "policy_context"; + +type RecommendationChange = { + status: "new" | "changed" | "unchanged"; + summary: string; + labels: Array<{ + kind: RecommendationSignalGroup; + label: string; + before?: string; + after?: string; + }>; +}; + +type RerunReasonGroup = { + group: RecommendationSignalGroup; + title: string; + reasons: string[]; +}; + type MinerDashboard = { status: "ready" | "needs_refresh"; login: string; - nextActions: Array>; + nextActions: Array< + Record & { + change?: RecommendationChange; + rerunReasons?: RerunReasonGroup[]; + } + >; blockers: Array<{ group: string; items: Array<{ code: string; title: string; howToClear: string }>; @@ -30,6 +64,8 @@ type MinerDashboard = { recommendation?: string; why?: string; rationale?: string; + change?: RecommendationChange; + rerunReasons?: RerunReasonGroup[]; } >; mcp?: { snapshot?: string | null; drift?: string | null; lastRun?: string | null }; @@ -112,6 +148,10 @@ export function MinerPanel() {
{stringField(action, "repoFullName", "repo pending")}
+ @@ -232,13 +272,16 @@ export function MinerPanel() { key={`${repo.repoFullName ?? index}`} className="border-b-hairline last:border-b-0 transition-colors hover:bg-muted/40" > - - {repo.repoFullName ?? "repo pending"} + +
+ {repo.repoFullName ?? "repo pending"} +
+ - + {lane} - + {repo.why ?? repo.rationale ?? repo.recommendation ?? @@ -261,3 +304,69 @@ function stringField(record: Record, key: string, fallback: str const value = record[key]; return typeof value === "string" && value.trim() ? value : fallback; } + +function RecommendationChangeDetails({ + change, + rerunReasons, +}: { + change?: RecommendationChange; + rerunReasons?: RerunReasonGroup[]; +}) { + const groups = rerunReasons?.filter((group) => group.reasons.length > 0) ?? []; + if (!change && groups.length === 0) return null; + return ( +
+ {change && ( +
+
+ {change.status} + {change.summary} +
+ {change.labels.length > 0 && ( +
+ {change.labels.map((label) => ( +
+
+ {label.label} +
+
+ {label.before ? `${label.before} -> ` : ""} + {label.after ?? "changed"} +
+
+ ))} +
+ )} +
+ )} + {groups.length > 0 && ( +
+ {groups.map((group) => ( +
+
+ {group.title} +
+
    + {group.reasons.slice(0, 2).map((reason) => ( +
  • + {reason} +
  • + ))} +
+
+ ))} +
+ )} +
+ ); +} + +function RecommendationChangeInline({ change }: { change?: RecommendationChange }) { + if (!change) return null; + return ( +
+ {change.status} + {change.summary} +
+ ); +} diff --git a/src/api/routes.ts b/src/api/routes.ts index 0d2458d3..fdbbca49 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -128,9 +128,15 @@ import { import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { buildAndPersistContributorDecisionPack, + CONTRIBUTOR_DECISION_PACK_SIGNAL, loadContributorDecisionPackForServing, repoDecisionFromPack, } from "../services/decision-pack"; +import { + buildMinerDashboardNextActions, + buildMinerDashboardRepoFit, + previousDecisionPackFromSnapshots, +} from "../services/miner-dashboard-recommendations"; import { buildStaticControlPanelRoleSummary, loadControlPanelAccessScope, @@ -788,11 +794,12 @@ export function createApp() { if (!login) return c.json({ error: "login_required" }, 400); const unauthorized = await requireContributorAccess(c, login); if (unauthorized) return unauthorized; - const [serving, scoring, upstreamDrift, runs] = await Promise.all([ + const [serving, scoring, upstreamDrift, runs, decisionPackSnapshots] = await Promise.all([ loadContributorDecisionPackForServing(c.env, login), getLatestScoringModelSnapshot(c.env), loadUpstreamStatus(c.env), listAgentRunsForActor(c.env, login, 5), + listSignalSnapshots(c.env, CONTRIBUTOR_DECISION_PACK_SIGNAL, login), ]); if (serving.kind === "needs_refresh") { return c.json({ @@ -808,21 +815,17 @@ export function createApp() { }); } const pack = serving.pack; + const previousPack = previousDecisionPackFromSnapshots(pack, decisionPackSnapshots); return c.json({ status: "ready", login, generatedAt: pack.generatedAt, source: pack.source, freshness: pack.freshness, - nextActions: pack.topActions ?? [], + nextActions: buildMinerDashboardNextActions(pack, previousPack), blockers: groupDecisionPackBlockers(pack.scoreBlockers ?? []), projections: buildProjectionRows(pack), - repoFit: [ - ...(pack.pursueRepos ?? []).map((repo) => ({ ...repo, lane: "pursue" })), - ...(pack.cleanupFirst ?? []).map((repo) => ({ ...repo, lane: "cleanup-first" })), - ...(pack.maintainerLaneRepos ?? []).map((repo) => ({ ...repo, lane: "maintainer-lane" })), - ...(pack.avoidRepos ?? []).map((repo) => ({ ...repo, lane: "avoid" })), - ], + repoFit: buildMinerDashboardRepoFit(pack, previousPack), dataQuality: pack.dataQuality, mcp: { snapshot: scoring?.id ?? null, drift: upstreamDrift.status, lastRun: runs[0]?.updatedAt ?? null }, }); diff --git a/src/services/miner-dashboard-recommendations.ts b/src/services/miner-dashboard-recommendations.ts new file mode 100644 index 00000000..f4b1bd9b --- /dev/null +++ b/src/services/miner-dashboard-recommendations.ts @@ -0,0 +1,393 @@ +import type { ContributorDecisionPack } from "./decision-pack"; +import type { SignalSnapshotRecord } from "../types"; + +export type MinerDashboardSignalGroup = "repo_state" | "contributor_state" | "validation_state" | "policy_context"; +export type MinerDashboardChangeStatus = "new" | "changed" | "unchanged"; + +export type MinerDashboardChangeLabel = { + kind: MinerDashboardSignalGroup; + label: string; + before?: string; + after?: string; +}; + +export type MinerDashboardRecommendationChange = { + status: MinerDashboardChangeStatus; + summary: string; + labels: MinerDashboardChangeLabel[]; +}; + +export type MinerDashboardRerunReasonGroup = { + group: MinerDashboardSignalGroup; + title: string; + reasons: string[]; +}; + +export type MinerDashboardRecommendationMetadata = { + change: MinerDashboardRecommendationChange; + rerunReasons: MinerDashboardRerunReasonGroup[]; +}; + +type DashboardRecord = Record; + +const GROUP_TITLES: Record = { + repo_state: "Repo state", + contributor_state: "Contributor state", + validation_state: "Validation state", + policy_context: "Policy/context state", +}; + +const GROUP_ORDER: MinerDashboardSignalGroup[] = ["repo_state", "contributor_state", "validation_state", "policy_context"]; +const CHANGE_LABEL_LIMIT = 6; +const REASON_LIMIT = 3; +const FORBIDDEN_PUBLIC_TEXT = + /\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|private keys?|raw[-_\s]?trust(?: scores?)?|trust[-_\s]?scores?|reward(?:[-_\s]?(?:estimate|prediction|claim|score))?s?|payouts?|farming(?:[-_\s]?language)?|private[-_\s]?reviewability|private[-_\s]?scoreability|scoreability|public[-_\s]?score[-_\s]?(?:estimate|prediction)|estimated[-_\s]?score|score[-_\s]?estimate)\b/gi; +const LOCAL_PATH = /(?:\/(?:Users|home|root|tmp|var)\/[^\s,;:)]+|[A-Za-z]:\\Users\\[^\s,;:)]+)/g; + +export function previousDecisionPackFromSnapshots(currentPack: ContributorDecisionPack, snapshots: SignalSnapshotRecord[]): ContributorDecisionPack | undefined { + const current = asRecord(currentPack); + const currentGeneratedAt = stringValue(current, "generatedAt"); + for (const snapshot of snapshots) { + const payload = asRecord(snapshot.payload); + if (!payload || stringValue(payload, "status") !== "ready") continue; + const generatedAt = stringValue(payload, "generatedAt") ?? snapshot.generatedAt; + if (generatedAt && currentGeneratedAt && generatedAt === currentGeneratedAt) continue; + return payload as unknown as ContributorDecisionPack; + } + return undefined; +} + +export function buildMinerDashboardNextActions( + pack: ContributorDecisionPack, + previousPack?: ContributorDecisionPack, +): Array { + const currentPack = asRecord(pack); + const previous = asRecord(previousPack); + const currentDecisions = repoRecordMap(recordArray(currentPack?.repoDecisions)); + const previousDecisions = repoRecordMap(recordArray(previous?.repoDecisions)); + const previousActions = actionRecordMaps(recordArray(previous?.topActions)); + const portfolio = portfolioRecordMaps(recordArray(asRecord(currentPack?.actionPortfolio)?.topActions)); + + return recordArray(currentPack?.topActions).map((action) => { + const repo = stringValue(action, "repoFullName"); + const currentDecision = repo ? currentDecisions.get(repo.toLowerCase()) : undefined; + const previousAction = actionLookup(action, previousActions); + const previousDecision = repo ? previousDecisions.get(repo.toLowerCase()) : undefined; + return { + ...action, + change: buildRecommendationChange({ + current: action, + currentDecision, + currentPack, + previous: previousAction, + previousDecision, + previousPack: previous, + }), + rerunReasons: buildRerunReasonGroups({ + current: action, + currentDecision, + currentPack, + portfolioItem: portfolioLookup(action, portfolio), + }), + }; + }); +} + +export function buildMinerDashboardRepoFit( + pack: ContributorDecisionPack, + previousPack?: ContributorDecisionPack, +): Array { + const currentPack = asRecord(pack); + const previous = asRecord(previousPack); + const currentRows = repoFitRows(currentPack); + const previousRows = repoRecordMap(repoFitRows(previous)); + const currentDecisions = repoRecordMap(recordArray(currentPack?.repoDecisions)); + const previousDecisions = repoRecordMap(recordArray(previous?.repoDecisions)); + + return currentRows.map((repo) => { + const repoFullName = stringValue(repo, "repoFullName"); + const previousRow = repoFullName ? previousRows.get(repoFullName.toLowerCase()) : undefined; + const currentDecision = repoFullName ? currentDecisions.get(repoFullName.toLowerCase()) ?? repo : repo; + const previousDecision = repoFullName ? previousDecisions.get(repoFullName.toLowerCase()) ?? previousRow : previousRow; + return { + ...repo, + change: buildRecommendationChange({ + current: repo, + currentDecision, + currentPack, + previous: previousRow, + previousDecision, + previousPack: previous, + }), + rerunReasons: buildRerunReasonGroups({ current: repo, currentDecision, currentPack }), + }; + }); +} + +function buildRecommendationChange(args: { + current: DashboardRecord; + currentDecision?: DashboardRecord | undefined; + currentPack?: DashboardRecord | undefined; + previous?: DashboardRecord | undefined; + previousDecision?: DashboardRecord | undefined; + previousPack?: DashboardRecord | undefined; +}): MinerDashboardRecommendationChange { + const labels: MinerDashboardChangeLabel[] = []; + const hasPrevious = Boolean(args.previous || args.previousDecision); + if (!hasPrevious) { + return { + status: "new", + summary: "New since the previous decision-pack run.", + labels: [{ kind: "repo_state", label: "New recommendation" }], + }; + } + + addChanged(labels, "repo_state", "Action changed", stringValue(args.previous, "actionKind"), stringValue(args.current, "actionKind")); + addChanged(labels, "repo_state", "Lane changed", stringValue(args.previous, "lane"), stringValue(args.current, "lane")); + addChanged( + labels, + "repo_state", + "Recommendation changed", + stringValue(args.previous, "recommendation") ?? stringValue(args.previousDecision, "recommendation"), + stringValue(args.current, "recommendation") ?? stringValue(args.currentDecision, "recommendation"), + ); + addChanged( + labels, + "repo_state", + "Priority bucket changed", + priorityBucket(numberValue(args.previous, "priorityScore") ?? numberValue(args.previousDecision, "priorityScore")), + priorityBucket(numberValue(args.current, "priorityScore") ?? numberValue(args.currentDecision, "priorityScore")), + ); + addChanged(labels, "repo_state", "Queue changed", queueSummary(args.previousDecision), queueSummary(args.currentDecision)); + addChanged(labels, "contributor_state", "Contributor PR state changed", outcomeSummary(args.previousDecision), outcomeSummary(args.currentDecision)); + addChanged(labels, "contributor_state", "Contributor lane changed", roleSummary(args.previousDecision), roleSummary(args.currentDecision)); + addChanged(labels, "validation_state", "Validation blockers changed", blockerSummary(args.previousDecision), blockerSummary(args.currentDecision)); + addChanged(labels, "policy_context", "Context freshness changed", packFidelityStatus(args.previousPack), packFidelityStatus(args.currentPack)); + addChanged(labels, "policy_context", "Repo policy changed", manifestSummary(args.previousDecision), manifestSummary(args.currentDecision)); + + const limited = labels.slice(0, CHANGE_LABEL_LIMIT); + if (limited.length === 0) { + return { status: "unchanged", summary: "No tracked evidence changed since the previous run.", labels: [] }; + } + + const changedGroups = [...new Set(limited.map((label) => GROUP_TITLES[label.kind]))].join(", "); + return { + status: "changed", + summary: `Changed since the previous run: ${changedGroups}.`, + labels: limited, + }; +} + +function buildRerunReasonGroups(args: { + current: DashboardRecord; + currentDecision?: DashboardRecord | undefined; + currentPack?: DashboardRecord | undefined; + portfolioItem?: DashboardRecord | undefined; +}): MinerDashboardRerunReasonGroup[] { + const queue = queueNumbers(args.currentDecision); + const blockers = blockerCodes(args.currentDecision); + const portfolioReason = sanitizePublicText(stringValue(args.portfolioItem, "rerunWhen") ?? ""); + const policyReason = packFidelityStatus(args.currentPack) && packFidelityStatus(args.currentPack) !== "complete" && packFidelityStatus(args.currentPack) !== "ok" + ? "Rerun after stale context refreshes or upstream policy data is rebuilt." + : "Rerun when repo policy, focus manifest, upstream rules, or cached context changes."; + + const reasons: Record = { + repo_state: [ + portfolioReason && /pr|queue|registry|issue/i.test(portfolioReason) + ? portfolioReason + : "Rerun when open PRs, issue counts, or registry lane data change.", + queue.openPullRequests > 0 || queue.openIssues > 0 + ? `Rerun after repo queue changes from ${queue.openPullRequests} open PR(s) and ${queue.openIssues} open issue(s).` + : "Rerun when a new issue, PR, merge, or closure changes queue pressure.", + ], + contributor_state: [ + outcomeOpenPullRequests(args.currentDecision) > 0 + ? "Rerun after your existing PRs in this repo merge, close, or are updated." + : "Rerun when contributor open work or recent outcomes change.", + "Rerun when the selected contribution lane or cleanup-first preference changes.", + ], + validation_state: [ + blockers.length > 0 + ? `Rerun after validation blockers change: ${blockers.join(", ")}.` + : "Rerun after local preflight, checks, or branch validation status changes.", + "Rerun when branch freshness or linked-issue validation changes.", + ], + policy_context: [policyReason, "Rerun when issue-quality or repository policy evidence changes."], + }; + + return GROUP_ORDER.map((group) => ({ + group, + title: GROUP_TITLES[group], + reasons: uniqueStrings(reasons[group].map((reason) => sanitizePublicText(reason)).filter(Boolean)).slice(0, REASON_LIMIT), + })); +} + +function repoFitRows(pack: DashboardRecord | undefined): DashboardRecord[] { + return [ + ...recordArray(pack?.pursueRepos).map((repo) => ({ ...repo, lane: "pursue" })), + ...recordArray(pack?.cleanupFirst).map((repo) => ({ ...repo, lane: "cleanup-first" })), + ...recordArray(pack?.maintainerLaneRepos).map((repo) => ({ ...repo, lane: "maintainer-lane" })), + ...recordArray(pack?.avoidRepos).map((repo) => ({ ...repo, lane: "avoid" })), + ]; +} + +function actionRecordMaps(actions: DashboardRecord[]): { byKey: Map; byRepo: Map } { + const byKey = new Map(); + const byRepo = new Map(); + for (const action of actions) { + const repo = stringValue(action, "repoFullName"); + const key = actionKey(action); + if (key) byKey.set(key, action); + if (repo && !byRepo.has(repo.toLowerCase())) byRepo.set(repo.toLowerCase(), action); + } + return { byKey, byRepo }; +} + +function actionLookup(action: DashboardRecord, maps: { byKey: Map; byRepo: Map }): DashboardRecord | undefined { + const key = actionKey(action); + const repo = stringValue(action, "repoFullName"); + return (key ? maps.byKey.get(key) : undefined) ?? (repo ? maps.byRepo.get(repo.toLowerCase()) : undefined); +} + +function portfolioRecordMaps(items: DashboardRecord[]): { byKey: Map; byRepo: Map } { + return actionRecordMaps(items); +} + +function portfolioLookup(action: DashboardRecord, maps: { byKey: Map; byRepo: Map }): DashboardRecord | undefined { + return actionLookup(action, maps); +} + +function repoRecordMap(records: DashboardRecord[]): Map { + const map = new Map(); + for (const record of records) { + const repo = stringValue(record, "repoFullName"); + if (repo && !map.has(repo.toLowerCase())) map.set(repo.toLowerCase(), record); + } + return map; +} + +function actionKey(action: DashboardRecord): string | undefined { + const repo = stringValue(action, "repoFullName"); + const kind = stringValue(action, "actionKind"); + return repo && kind ? `${repo.toLowerCase()}:${kind}` : undefined; +} + +function addChanged( + labels: MinerDashboardChangeLabel[], + kind: MinerDashboardSignalGroup, + label: string, + before: string | undefined, + after: string | undefined, +): void { + const safeBefore = sanitizePublicText(before ?? ""); + const safeAfter = sanitizePublicText(after ?? ""); + if (!safeBefore && !safeAfter) return; + if (safeBefore === safeAfter) return; + labels.push({ + kind, + label, + ...(safeBefore ? { before: safeBefore } : {}), + ...(safeAfter ? { after: safeAfter } : {}), + }); +} + +function priorityBucket(value: number | undefined): string | undefined { + if (typeof value !== "number") return undefined; + if (value >= 70) return "high"; + if (value >= 40) return "medium"; + if (value > 0) return "low"; + return "none"; +} + +function queueSummary(decision: DashboardRecord | undefined): string | undefined { + const queue = queueNumbers(decision); + if (!queue.present) return undefined; + return `${queue.openPullRequests} PR / ${queue.openIssues} issue`; +} + +function queueNumbers(decision: DashboardRecord | undefined): { present: boolean; openPullRequests: number; openIssues: number } { + const queue = asRecord(decision?.queue); + const openPullRequests = numberValue(queue, "openPullRequests") ?? 0; + const openIssues = numberValue(queue, "openIssues") ?? 0; + return { present: Boolean(queue), openPullRequests, openIssues }; +} + +function outcomeSummary(decision: DashboardRecord | undefined): string | undefined { + const outcome = asRecord(decision?.outcome); + if (!outcome) return undefined; + const openPullRequests = numberValue(outcome, "openPullRequests") ?? 0; + const mergedPullRequests = numberValue(outcome, "mergedPullRequests") ?? 0; + const closedPullRequests = numberValue(outcome, "closedPullRequests") ?? 0; + return `${openPullRequests} open / ${mergedPullRequests} merged / ${closedPullRequests} closed`; +} + +function outcomeOpenPullRequests(decision: DashboardRecord | undefined): number { + return numberValue(asRecord(decision?.outcome), "openPullRequests") ?? 0; +} + +function roleSummary(decision: DashboardRecord | undefined): string | undefined { + const role = asRecord(decision?.roleContext); + if (!role) return undefined; + const roleName = stringValue(role, "role") ?? stringValue(role, "lane") ?? "contributor"; + const maintainerLane = Boolean(role.maintainerLane); + return `${roleName}${maintainerLane ? " maintainer-lane" : ""}`; +} + +function blockerSummary(decision: DashboardRecord | undefined): string | undefined { + const codes = blockerCodes(decision); + return codes.length > 0 ? codes.join(", ") : "none"; +} + +function blockerCodes(decision: DashboardRecord | undefined): string[] { + return recordArray(decision?.scoreBlockers) + .map((blocker, index) => stringValue(blocker, "code") ?? `validation_${index + 1}`) + .map((code) => sanitizePublicText(code)) + .filter(Boolean) + .sort(); +} + +function packFidelityStatus(pack: DashboardRecord | undefined): string | undefined { + const dataQuality = asRecord(pack?.dataQuality); + const signalFidelity = asRecord(dataQuality?.signalFidelity); + return stringValue(signalFidelity, "status"); +} + +function manifestSummary(decision: DashboardRecord | undefined): string | undefined { + const manifest = asRecord(decision?.manifestSummary); + if (!manifest) return undefined; + const linkedIssuePolicy = stringValue(manifest, "linkedIssuePolicy") ?? "unknown"; + const issueDiscoveryPolicy = stringValue(manifest, "issueDiscoveryPolicy") ?? "unknown"; + const wantedPathCount = numberValue(manifest, "wantedPathCount") ?? 0; + const blockedPathCount = numberValue(manifest, "blockedPathCount") ?? 0; + return `${linkedIssuePolicy}/${issueDiscoveryPolicy}/${wantedPathCount} wanted/${blockedPathCount} blocked`; +} + +function recordArray(value: unknown): DashboardRecord[] { + return Array.isArray(value) ? value.map(asRecord).filter((entry): entry is DashboardRecord => Boolean(entry)) : []; +} + +function asRecord(value: unknown): DashboardRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as DashboardRecord) : undefined; +} + +function stringValue(record: DashboardRecord | undefined, key: string): string | undefined { + const value = record?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function numberValue(record: DashboardRecord | undefined, key: string): number | undefined { + const value = record?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function sanitizePublicText(value: string): string { + return value + .replace(LOCAL_PATH, "[local path]") + .replace(FORBIDDEN_PUBLIC_TEXT, "private context") + .replace(/\s+/g, " ") + .trim(); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 7618fd9f..bd8260ef 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1734,6 +1734,100 @@ describe("api routes", () => { mcp: expect.objectContaining({ snapshot: "scoring-1" }), }); + await persistSignalSnapshot(env, { + id: "rerun-pack-previous", + signalType: "contributor-decision-pack", + targetKey: "rerun-user", + payload: { + status: "ready", + source: "computed", + login: "rerun-user", + generatedAt: "2026-05-27T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + repoDecisions: [ + { + repoFullName: "JSONbored/gittensory", + recommendation: "watch", + priorityScore: 35, + queue: { openPullRequests: 0, openIssues: 1, mergedPullRequests: 0, closedUnmergedPullRequests: 0 }, + outcome: { openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0 }, + roleContext: { role: "contributor", maintainerLane: false }, + scoreBlockers: [], + }, + ], + topActions: [{ repoFullName: "JSONbored/gittensory", actionKind: "open_new_direct_pr", recommendation: "watch", priorityScore: 35 }], + pursueRepos: [{ repoFullName: "JSONbored/gittensory", recommendation: "watch", priorityScore: 35 }], + cleanupFirst: [], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete" } }, + } as never, + generatedAt: "2026-05-27T00:00:00.000Z", + }); + await persistSignalSnapshot(env, { + id: "rerun-pack-current", + signalType: "contributor-decision-pack", + targetKey: "rerun-user", + payload: { + status: "ready", + source: "computed", + login: "rerun-user", + generatedAt: "2026-05-28T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + repoDecisions: [ + { + repoFullName: "JSONbored/gittensory", + recommendation: "pursue", + priorityScore: 82, + queue: { openPullRequests: 2, openIssues: 4, mergedPullRequests: 1, closedUnmergedPullRequests: 0 }, + outcome: { openPullRequests: 1, mergedPullRequests: 1, closedPullRequests: 0 }, + roleContext: { role: "contributor", maintainerLane: false }, + scoreBlockers: [{ code: "open_pr_pressure", detail: "private scoreability must stay private" }], + }, + ], + topActions: [{ repoFullName: "JSONbored/gittensory", actionKind: "open_new_direct_pr", recommendation: "pursue", priorityScore: 82 }], + actionPortfolio: { + topActions: [{ repoFullName: "JSONbored/gittensory", actionKind: "open_new_direct_pr", rerunWhen: "Rerun when queue changes." }], + }, + pursueRepos: [{ repoFullName: "JSONbored/gittensory", recommendation: "pursue", priorityScore: 82 }], + cleanupFirst: [], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "degraded" } }, + } as never, + generatedAt: "2026-05-28T00:00:00.000Z", + }); + const minerWithRerunReasons = await app.request("/v1/app/miner-dashboard?login=rerun-user", { headers: apiHeaders(env) }, env); + expect(minerWithRerunReasons.status).toBe(200); + const minerWithRerunReasonsBody = (await minerWithRerunReasons.json()) as { + nextActions: Array<{ change?: { status: string; labels: Array<{ kind: string }> }; rerunReasons?: Array<{ group: string }> }>; + }; + expect(minerWithRerunReasonsBody.nextActions[0]?.change).toMatchObject({ + status: "changed", + labels: expect.arrayContaining([ + expect.objectContaining({ kind: "repo_state" }), + expect.objectContaining({ kind: "validation_state" }), + expect.objectContaining({ kind: "policy_context" }), + ]), + }); + expect(minerWithRerunReasonsBody.nextActions[0]?.rerunReasons?.map((group) => group.group)).toEqual([ + "repo_state", + "contributor_state", + "validation_state", + "policy_context", + ]); + expect(JSON.stringify(minerWithRerunReasonsBody.nextActions[0])).not.toMatch( + /wallet|hotkey|raw trust|trust[-\s]?score|payout|reward[-\s]?estimate|farming|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)|private[-\s]?scoreability|scoreability/i, + ); + await persistSignalSnapshot(env, { id: "blocker-pack", signalType: "contributor-decision-pack", diff --git a/test/unit/miner-dashboard-recommendations.test.ts b/test/unit/miner-dashboard-recommendations.test.ts new file mode 100644 index 00000000..ecd96594 --- /dev/null +++ b/test/unit/miner-dashboard-recommendations.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; +import { + buildMinerDashboardNextActions, + buildMinerDashboardRepoFit, + previousDecisionPackFromSnapshots, +} from "../../src/services/miner-dashboard-recommendations"; +import type { ContributorDecisionPack } from "../../src/services/decision-pack"; +import type { SignalSnapshotRecord } from "../../src/types"; + +const FORBIDDEN_PUBLIC_CHANGE_TEXT = + /wallet|hotkey|coldkey|raw trust|trust[-\s]?score|payout|reward[-\s]?estimate|farming|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)|private[-\s]?scoreability|scoreability|\/Users|\/home|\/tmp|github_pat|ghp_/i; + +describe("miner dashboard recommendation metadata", () => { + it("builds deterministic changed-since-last-run labels and grouped rerun reasons", () => { + const previous = decisionPack({ + generatedAt: "2026-06-01T00:00:00.000Z", + repoDecisions: [ + repoDecision({ + recommendation: "watch", + priorityScore: 35, + queue: { openPullRequests: 0, openIssues: 1, mergedPullRequests: 0, closedUnmergedPullRequests: 0 }, + scoreBlockers: [], + manifestSummary: { + linkedIssuePolicy: "optional", + issueDiscoveryPolicy: "allowed", + wantedPathCount: 1, + blockedPathCount: 0, + }, + }), + ], + topActions: [action({ recommendation: "watch", priorityScore: 35 })], + pursueRepos: [repoDecision({ recommendation: "watch", priorityScore: 35 })], + dataQuality: { signalFidelity: { status: "complete" } }, + }); + const current = decisionPack({ + generatedAt: "2026-06-02T00:00:00.000Z", + repoDecisions: [ + repoDecision({ + recommendation: "pursue", + priorityScore: 82, + queue: { openPullRequests: 3, openIssues: 4, mergedPullRequests: 1, closedUnmergedPullRequests: 0 }, + scoreBlockers: [{ code: "open_pr_pressure", detail: "wallet hotkey scoreability reward estimate" }], + manifestSummary: { + linkedIssuePolicy: "required", + issueDiscoveryPolicy: "restricted", + wantedPathCount: 2, + blockedPathCount: 1, + }, + }), + ], + topActions: [action({ recommendation: "pursue", priorityScore: 82 })], + actionPortfolio: { + topActions: [ + { + repoFullName: "JSONbored/gittensory", + actionKind: "open_new_direct_pr", + rerunWhen: "Rerun when queue changes, not wallet hotkey scoreability reward estimate.", + }, + ], + }, + pursueRepos: [repoDecision({ recommendation: "pursue", priorityScore: 82 })], + dataQuality: { signalFidelity: { status: "degraded" } }, + }); + + const [enriched] = buildMinerDashboardNextActions(current, previous); + + expect(enriched?.change).toMatchObject({ + status: "changed", + labels: expect.arrayContaining([ + expect.objectContaining({ kind: "repo_state", label: "Recommendation changed", before: "watch", after: "pursue" }), + expect.objectContaining({ kind: "repo_state", label: "Priority bucket changed", before: "low", after: "high" }), + expect.objectContaining({ kind: "repo_state", label: "Queue changed", before: "0 PR / 1 issue", after: "3 PR / 4 issue" }), + expect.objectContaining({ kind: "validation_state", label: "Validation blockers changed", before: "none", after: "open_pr_pressure" }), + expect.objectContaining({ kind: "policy_context", label: "Context freshness changed", before: "complete", after: "degraded" }), + ]), + }); + expect(enriched?.rerunReasons.map((group) => group.group)).toEqual([ + "repo_state", + "contributor_state", + "validation_state", + "policy_context", + ]); + expect(JSON.stringify({ change: enriched?.change, rerunReasons: enriched?.rerunReasons })).not.toMatch(FORBIDDEN_PUBLIC_CHANGE_TEXT); + }); + + it("marks unchanged recommendations when tracked evidence is stable", () => { + const previous = decisionPack({ generatedAt: "2026-06-01T00:00:00.000Z" }); + const current = decisionPack({ generatedAt: "2026-06-02T00:00:00.000Z" }); + + const [enriched] = buildMinerDashboardNextActions(current, previous); + + expect(enriched?.change).toEqual({ + status: "unchanged", + summary: "No tracked evidence changed since the previous run.", + labels: [], + }); + }); + + it("marks new recommendations and builds fallback rerun groups for sparse records", () => { + const current = decisionPack({ + repoDecisions: [ + repoDecision({ + repoFullName: "owner/sparse", + recommendation: "avoid_for_now", + priorityScore: 0, + queue: {}, + outcome: { openPullRequests: 2 }, + roleContext: { lane: "issue_discovery", maintainerLane: true }, + scoreBlockers: [{}], + manifestSummary: {}, + }), + ], + topActions: [{ repoFullName: "owner/sparse", recommendation: "avoid_for_now", priorityScore: 0 }], + actionPortfolio: { topActions: [] }, + pursueRepos: [], + avoidRepos: [repoDecision({ repoFullName: "owner/sparse", recommendation: "avoid_for_now", priorityScore: 0 })], + maintainerLaneRepos: [repoDecision({ repoFullName: "owner/maintainer", recommendation: "maintainer_lane" })], + }); + + const [action] = buildMinerDashboardNextActions(current); + const repoFit = buildMinerDashboardRepoFit(current); + + expect(action?.change).toEqual({ + status: "new", + summary: "New since the previous decision-pack run.", + labels: [{ kind: "repo_state", label: "New recommendation" }], + }); + expect(action?.rerunReasons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ group: "contributor_state", reasons: expect.arrayContaining(["Rerun after your existing PRs in this repo merge, close, or are updated."]) }), + expect.objectContaining({ group: "validation_state", reasons: expect.arrayContaining(["Rerun after validation blockers change: validation_1."]) }), + ]), + ); + expect(repoFit.map((repo) => repo.lane)).toEqual(["maintainer-lane", "avoid"]); + }); + + it("reports medium-to-none priority changes", () => { + const previous = decisionPack({ + generatedAt: "2026-06-01T00:00:00.000Z", + repoDecisions: [repoDecision({ priorityScore: 45, roleContext: { role: "contributor", maintainerLane: true } })], + topActions: [action({ priorityScore: 45 })], + }); + const current = decisionPack({ + generatedAt: "2026-06-02T00:00:00.000Z", + repoDecisions: [repoDecision({ priorityScore: 0, roleContext: { role: "contributor", maintainerLane: true } })], + topActions: [action({ priorityScore: 0 })], + }); + + expect(buildMinerDashboardNextActions(current, previous)[0]?.change.labels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "repo_state", label: "Priority bucket changed", before: "medium", after: "none" }), + ]), + ); + }); + + it("falls back to decision evidence when action and repo-fit rows are sparse", () => { + const previous = decisionPack({ + generatedAt: "2026-06-01T00:00:00.000Z", + repoDecisions: [ + { + repoFullName: "owner/fallback", + recommendation: "watch", + priorityScore: 45, + scoreBlockers: [], + manifestSummary: {}, + }, + ], + topActions: [ + {}, + { repoFullName: "owner/fallback", actionKind: "file_issue_discovery" }, + { repoFullName: "owner/fallback", actionKind: "duplicate_entry" }, + ], + pursueRepos: [{ recommendation: "watch" }], + }); + const current = decisionPack({ + generatedAt: "2026-06-02T00:00:00.000Z", + repoDecisions: [ + { + repoFullName: "owner/fallback", + recommendation: "pursue", + priorityScore: 0, + scoreBlockers: [], + manifestSummary: { + linkedIssuePolicy: "required", + issueDiscoveryPolicy: "restricted", + wantedPathCount: 2, + blockedPathCount: 1, + }, + }, + ], + topActions: [ + { actionKind: "open_new_direct_pr" }, + { repoFullName: "owner/fallback", actionKind: "open_new_direct_pr" }, + ], + pursueRepos: [{ recommendation: "pursue" }, { repoFullName: "owner/fallback", recommendation: "pursue" }], + }); + + const [, fallbackAction] = buildMinerDashboardNextActions(current, previous); + const [repoWithoutName] = buildMinerDashboardRepoFit(current, previous); + + expect(fallbackAction?.change.labels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "Action changed", before: "file_issue_discovery", after: "open_new_direct_pr" }), + expect.objectContaining({ label: "Recommendation changed", before: "watch", after: "pursue" }), + expect.objectContaining({ label: "Priority bucket changed", before: "medium", after: "none" }), + expect.objectContaining({ label: "Repo policy changed", before: "unknown/unknown/0 wanted/0 blocked", after: "required/restricted/2 wanted/1 blocked" }), + ]), + ); + expect(repoWithoutName?.change.status).toBe("new"); + }); + + it("adds lane change evidence to repo fit rows", () => { + const previous = decisionPack({ + generatedAt: "2026-06-01T00:00:00.000Z", + pursueRepos: [repoDecision({ recommendation: "pursue" })], + }); + const current = decisionPack({ + generatedAt: "2026-06-02T00:00:00.000Z", + cleanupFirst: [repoDecision({ recommendation: "cleanup_first" })], + pursueRepos: [], + }); + + const [repoFit] = buildMinerDashboardRepoFit(current, previous); + + expect(repoFit?.change).toMatchObject({ + status: "changed", + labels: expect.arrayContaining([ + expect.objectContaining({ kind: "repo_state", label: "Lane changed", before: "pursue", after: "cleanup-first" }), + ]), + }); + }); + + it("selects the previous ready decision-pack snapshot", () => { + const current = decisionPack({ generatedAt: "2026-06-02T00:00:00.000Z" }); + const previous = decisionPack({ generatedAt: "2026-06-01T00:00:00.000Z" }); + + expect(previousDecisionPackFromSnapshots(current, [snapshot("current", current), snapshot("previous", previous)])?.generatedAt).toBe( + "2026-06-01T00:00:00.000Z", + ); + }); + + it("skips non-ready and current decision-pack snapshots", () => { + const current = decisionPack({ generatedAt: "2026-06-02T00:00:00.000Z" }); + + expect( + previousDecisionPackFromSnapshots(current, [ + { + id: "refresh", + signalType: "contributor-decision-pack", + targetKey: "miner", + payload: { status: "needs_snapshot_refresh", generatedAt: "2026-06-01T00:00:00.000Z" }, + generatedAt: "2026-06-01T00:00:00.000Z", + } as SignalSnapshotRecord, + snapshot("current", current), + ]), + ).toBeUndefined(); + }); + + it("uses the snapshot timestamp when a previous ready payload has no generatedAt", () => { + const current = decisionPack({ generatedAt: "2026-06-02T00:00:00.000Z" }); + const previous = decisionPack({ generatedAt: undefined }); + + expect( + previousDecisionPackFromSnapshots(current, [ + { + ...snapshot("previous", previous), + payload: { ...previous, generatedAt: undefined } as unknown as SignalSnapshotRecord["payload"], + generatedAt: "2026-06-01T00:00:00.000Z", + }, + ])?.login, + ).toBe("miner"); + }); +}); + +function decisionPack(overrides: Record = {}): ContributorDecisionPack { + return { + status: "ready", + source: "computed", + login: "miner", + generatedAt: "2026-06-02T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + repoDecisions: [repoDecision()], + topActions: [action()], + actionPortfolio: { topActions: [] }, + cleanupFirst: [], + pursueRepos: [repoDecision()], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete" } }, + ...overrides, + } as unknown as ContributorDecisionPack; +} + +function repoDecision(overrides: Record = {}): Record { + return { + repoFullName: "JSONbored/gittensory", + recommendation: "pursue", + priorityScore: 82, + queue: { openPullRequests: 1, openIssues: 2, mergedPullRequests: 1, closedUnmergedPullRequests: 0 }, + outcome: { openPullRequests: 0, mergedPullRequests: 1, closedPullRequests: 0 }, + roleContext: { role: "contributor", maintainerLane: false }, + scoreBlockers: [], + manifestSummary: { + linkedIssuePolicy: "required", + issueDiscoveryPolicy: "allowed", + wantedPathCount: 1, + blockedPathCount: 0, + }, + ...overrides, + }; +} + +function action(overrides: Record = {}): Record { + return { + repoFullName: "JSONbored/gittensory", + actionKind: "open_new_direct_pr", + recommendation: "pursue", + priorityScore: 82, + whyThisHelps: ["Pick a narrow, public-safe change."], + nextActions: ["Run local preflight."], + publicNextActions: ["Run local preflight."], + ...overrides, + }; +} + +function snapshot(id: string, pack: ContributorDecisionPack): SignalSnapshotRecord { + return { + id, + signalType: "contributor-decision-pack", + targetKey: "miner", + payload: pack as unknown as SignalSnapshotRecord["payload"], + generatedAt: pack.generatedAt, + }; +}