From 4f8f870f5ac4fd713a59961acc0afdee9f98f4b3 Mon Sep 17 00:00:00 2001 From: bittoby <218712309+bittoby@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:43:28 +0000 Subject: [PATCH] feat(app): display decision snapshot replay details --- .../src/components/site/snapshot-replay.tsx | 190 +++++++++++ apps/gittensory-ui/src/lib/snapshot-replay.ts | 314 ++++++++++++++++++ apps/gittensory-ui/src/routes/app.runs.tsx | 44 +++ test/unit/snapshot-replay-ui.test.ts | 189 +++++++++++ 4 files changed, 737 insertions(+) create mode 100644 apps/gittensory-ui/src/components/site/snapshot-replay.tsx create mode 100644 apps/gittensory-ui/src/lib/snapshot-replay.ts create mode 100644 test/unit/snapshot-replay-ui.test.ts diff --git a/apps/gittensory-ui/src/components/site/snapshot-replay.tsx b/apps/gittensory-ui/src/components/site/snapshot-replay.tsx new file mode 100644 index 0000000000..a3943fe35b --- /dev/null +++ b/apps/gittensory-ui/src/components/site/snapshot-replay.tsx @@ -0,0 +1,190 @@ +import { useState, type ReactNode } from "react"; + +import { StatusPill, type Status } from "@/components/site/control-primitives"; +import { cn } from "@/lib/utils"; +import type { SnapshotReplayView, SnapshotReplayViewer } from "@/lib/snapshot-replay"; + +const STATUS_PILL: Record = { + populated: "ready", + stale: "stale", + missing: "info", +}; + +const STATUS_LABEL: Record = { + populated: "Replayable", + stale: "Stale evidence", + missing: "No evidence", +}; + +const VIEWER_LABEL: Record = { + authenticated: "Authenticated", + public: "Public-safe", +}; + +/** + * Snapshot replay card with an audience toggle so reviewers can confirm the + * public-safe view withholds the private detail the authenticated view shows. + * The two views are precomputed by the caller; switching never re-derives or + * exposes withheld fields. + */ +export function SnapshotReplayCard({ + authenticated, + publicSafe, +}: { + authenticated: SnapshotReplayView; + publicSafe: SnapshotReplayView; +}) { + const [viewer, setViewer] = useState("authenticated"); + const view = viewer === "public" ? publicSafe : authenticated; + return ( +
+
+ {(["authenticated", "public"] as const).map((option) => ( + + ))} +
+ +
+ ); +} + +/** + * Inspection-only replay view for a single decision snapshot (issue #285). + * Renders public-safe provenance, freshness/confidence context, evidence gaps, + * and counterfactuals, with private detail clearly separated and withheld for + * public viewers. It intentionally exposes no mutating actions. + */ +export function SnapshotReplay({ view }: { view: SnapshotReplayView }) { + return ( +
+
+
+
+ Decision snapshot replay +
+
+ {view.snapshotId ?? "unknown snapshot"} +
+
+ {STATUS_LABEL[view.status]} +
+ +

{view.notice}

+ + {view.status === "missing" ? null : ( + <> +
+ + + + + + +
+ + {view.sources.length > 0 && ( +
+ Evidence sources +
    + {view.sources.map((source) => ( +
  • + {source.name} + + {source.freshness} + {source.generatedAt ? ` · ${source.generatedAt}` : ""} + +
  • + ))} +
+
+ )} + + {view.staleReasons.length > 0 && ( +
+ Evidence caveats +
    + {view.staleReasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+ )} + + {view.counterfactuals.length > 0 && ( +
+ Why not the alternatives +
+ {view.counterfactuals.map((cf) => ( +
+
+ {cf.repoFullName} · chose {cf.recommendation} +
+
    + {cf.alternatives.map((alt, index) => ( +
  • + {alt.publicSummary} + {alt.reason ? ( + {alt.reason} + ) : null} +
  • + ))} +
+
+ ))} +
+
+ )} + + {view.withheldPrivateFields.length > 0 && ( +

+ Private detail withheld for this context: {view.withheldPrivateFields.join(", ")}. +

+ )} + + )} +
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + +function SubHeading({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/gittensory-ui/src/lib/snapshot-replay.ts b/apps/gittensory-ui/src/lib/snapshot-replay.ts new file mode 100644 index 0000000000..1a2108b3d6 --- /dev/null +++ b/apps/gittensory-ui/src/lib/snapshot-replay.ts @@ -0,0 +1,314 @@ +// Decision snapshot replay view model. +// +// Turns a persisted recommendation snapshot envelope (see +// `src/services/recommendation-snapshots.ts`) plus its run's counterfactual +// reasons into a human-readable, inspection-only replay view for the control +// panel (issue #285). It is deterministic and fails closed: malformed or +// missing evidence is represented explicitly rather than silently omitted, and +// private/authenticated detail is withheld for public viewers. +// +// This module is intentionally standalone (no imports) so it can be unit-tested +// directly and reused by the UI without pulling in worker-side types. + +export type SnapshotReplayViewer = "public" | "authenticated"; +export type SnapshotReplayStatus = "populated" | "stale" | "missing"; +export type SnapshotReplayConfidence = "high" | "medium" | "low" | "unknown"; +export type SnapshotReplayFreshness = + | "fresh" + | "stale" + | "rebuilding" + | "missing" + | "degraded" + | "possibly_stale" + | "unknown"; + +export type SnapshotReplaySource = { + name: string; + freshness: SnapshotReplayFreshness; + generatedAt: string | null; +}; + +export type SnapshotReplayCounterfactualAlternative = { + alternative: string; + group: string; + publicSummary: string; + // Private/authenticated-only detail. Always null/empty for public viewers. + reason: string | null; + facts: string[]; + assumptions: string[]; +}; + +export type SnapshotReplayCounterfactual = { + repoFullName: string; + recommendation: string; + alternatives: SnapshotReplayCounterfactualAlternative[]; +}; + +export type SnapshotReplayTarget = { + repoFullName: string | null; + pullNumber: number | null; + issueNumber: number | null; +}; + +export type SnapshotReplayView = { + status: SnapshotReplayStatus; + viewer: SnapshotReplayViewer; + snapshotId: string | null; + actionType: string | null; + target: SnapshotReplayTarget; + generatedAt: string | null; + scoringModelId: string | null; + confidence: SnapshotReplayConfidence; + freshness: SnapshotReplayFreshness; + sources: SnapshotReplaySource[]; + evidenceGaps: string[]; + evidenceComplete: boolean; + staleReasons: string[]; + counterfactuals: SnapshotReplayCounterfactual[]; + withheldPrivateFields: string[]; + notice: string; +}; + +export type BuildSnapshotReplayViewInput = { + snapshot: unknown; + counterfactuals?: unknown; + viewer: SnapshotReplayViewer; +}; + +const CONFIDENCE_VALUES: ReadonlySet = new Set([ + "high", + "medium", + "low", + "unknown", +]); +const FRESHNESS_VALUES: ReadonlySet = new Set([ + "fresh", + "stale", + "rebuilding", + "missing", + "degraded", + "possibly_stale", + "unknown", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ); +} + +function narrowConfidence(value: unknown): SnapshotReplayConfidence { + return typeof value === "string" && CONFIDENCE_VALUES.has(value as SnapshotReplayConfidence) + ? (value as SnapshotReplayConfidence) + : "unknown"; +} + +function narrowFreshness(value: unknown): SnapshotReplayFreshness { + return typeof value === "string" && FRESHNESS_VALUES.has(value as SnapshotReplayFreshness) + ? (value as SnapshotReplayFreshness) + : "unknown"; +} + +function readTarget(value: unknown): SnapshotReplayTarget { + if (!isRecord(value)) return { repoFullName: null, pullNumber: null, issueNumber: null }; + return { + repoFullName: asString(value.repoFullName), + pullNumber: asNumber(value.pullNumber), + issueNumber: asNumber(value.issueNumber), + }; +} + +function readSources(value: unknown): SnapshotReplaySource[] { + if (!Array.isArray(value)) return []; + const sources: SnapshotReplaySource[] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const name = asString(entry.name); + if (!name) continue; + sources.push({ + name, + freshness: narrowFreshness(entry.freshness), + generatedAt: asString(entry.generatedAt), + }); + } + return sources; +} + +function sameRepo(a: string | null, b: string | null): boolean { + return Boolean(a) && Boolean(b) && a!.toLowerCase() === b!.toLowerCase(); +} + +function readCounterfactuals( + value: unknown, + targetRepoFullName: string | null, +): SnapshotReplayCounterfactual[] { + if (!Array.isArray(value)) return []; + const result: SnapshotReplayCounterfactual[] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const repoFullName = asString(entry.repoFullName); + if (targetRepoFullName && repoFullName && !sameRepo(repoFullName, targetRepoFullName)) continue; + const alternatives = readAlternatives(entry.rejectedAlternatives); + if (alternatives.length === 0) continue; + result.push({ + repoFullName: repoFullName ?? "unknown", + recommendation: asString(entry.recommendation) ?? "unknown", + alternatives, + }); + } + return result; +} + +function readAlternatives(value: unknown): SnapshotReplayCounterfactualAlternative[] { + if (!Array.isArray(value)) return []; + const alternatives: SnapshotReplayCounterfactualAlternative[] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const publicSummary = asString(entry.publicSummary); + const reason = asString(entry.reason); + // Skip entries that carry neither a public summary nor a private reason. + if (!publicSummary && !reason) continue; + alternatives.push({ + alternative: asString(entry.alternative) ?? "alternative", + group: asString(entry.group) ?? "other", + publicSummary: publicSummary ?? "Alternative considered.", + reason, + facts: asStringList(entry.facts), + assumptions: asStringList(entry.assumptions), + }); + } + return alternatives; +} + +/** + * Project counterfactuals for the viewer. Public viewers keep only the + * public-safe summary; the private reason/facts/assumptions are withheld and + * the withholding is recorded explicitly. + */ +function projectCounterfactuals( + counterfactuals: SnapshotReplayCounterfactual[], + viewer: SnapshotReplayViewer, +): { counterfactuals: SnapshotReplayCounterfactual[]; withheldPrivateFields: string[] } { + if (viewer === "authenticated") return { counterfactuals, withheldPrivateFields: [] }; + + let withheldPrivate = false; + const projected = counterfactuals.map((cf) => ({ + ...cf, + alternatives: cf.alternatives.map((alt) => { + if (alt.reason || alt.facts.length > 0 || alt.assumptions.length > 0) withheldPrivate = true; + return { ...alt, reason: null, facts: [], assumptions: [] }; + }), + })); + return { + counterfactuals: projected, + withheldPrivateFields: withheldPrivate ? ["counterfactual_detail"] : [], + }; +} + +function missingView( + viewer: SnapshotReplayViewer, + notice: string, + partial?: Partial, +): SnapshotReplayView { + return { + status: "missing", + viewer, + snapshotId: null, + actionType: null, + target: { repoFullName: null, pullNumber: null, issueNumber: null }, + generatedAt: null, + scoringModelId: null, + confidence: "unknown", + freshness: "missing", + sources: [], + evidenceGaps: [], + evidenceComplete: false, + staleReasons: [], + counterfactuals: [], + withheldPrivateFields: [], + notice, + ...partial, + }; +} + +/** + * Build a public-safe-aware, inspection-only replay view for a single decision + * snapshot. Returns a `missing` view when no snapshot/provenance is present. + */ +export function buildSnapshotReplayView(input: BuildSnapshotReplayViewInput): SnapshotReplayView { + const { viewer, snapshot } = input; + if (!isRecord(snapshot)) { + return missingView(viewer, "No decision snapshot is available to replay."); + } + + const snapshotId = asString(snapshot.snapshotId); + const actionType = asString(snapshot.actionType); + const target = readTarget(snapshot.target); + const generatedAt = asString(snapshot.generatedAt); + + const provenance = isRecord(snapshot.provenance) ? snapshot.provenance : null; + if (!provenance) { + return missingView(viewer, "This snapshot has no provenance to replay.", { + snapshotId, + actionType, + target, + generatedAt, + }); + } + + const confidence = narrowConfidence(provenance.confidence); + const freshness = narrowFreshness(provenance.freshness); + const scoringModelId = asString(provenance.scoringModelId); + const sources = readSources(provenance.sources); + const evidenceGaps = asStringList(provenance.evidenceGaps); + const evidenceComplete = provenance.evidenceComplete === true; + + const matchedCounterfactuals = readCounterfactuals(input.counterfactuals, target.repoFullName); + const { counterfactuals, withheldPrivateFields } = projectCounterfactuals( + matchedCounterfactuals, + viewer, + ); + + const staleReasons: string[] = []; + if (freshness !== "fresh") staleReasons.push(`Snapshot freshness is ${freshness}.`); + if (!evidenceComplete) staleReasons.push("Evidence is incomplete."); + for (const gap of evidenceGaps) staleReasons.push(`Evidence gap — ${gap}.`); + + const status: SnapshotReplayStatus = staleReasons.length > 0 ? "stale" : "populated"; + const notice = + status === "populated" + ? "All replayed evidence is fresh and complete." + : `Replaying with ${staleReasons.length} evidence caveat${staleReasons.length === 1 ? "" : "s"}.`; + + return { + status, + viewer, + snapshotId, + actionType, + target, + generatedAt, + scoringModelId, + confidence, + freshness, + sources, + evidenceGaps, + evidenceComplete, + staleReasons, + counterfactuals, + withheldPrivateFields, + notice, + }; +} diff --git a/apps/gittensory-ui/src/routes/app.runs.tsx b/apps/gittensory-ui/src/routes/app.runs.tsx index 64cf289288..6383d5e01e 100644 --- a/apps/gittensory-ui/src/routes/app.runs.tsx +++ b/apps/gittensory-ui/src/routes/app.runs.tsx @@ -27,6 +27,10 @@ import { useSession } from "@/lib/api/session"; import { EmptyState } from "@/components/site/state-views"; import { useLocalStorage } from "@/lib/use-local-storage"; import { cn } from "@/lib/utils"; +import { SnapshotReplayCard } from "@/components/site/snapshot-replay"; +import { buildSnapshotReplayView, type SnapshotReplayView } from "@/lib/snapshot-replay"; + +type SnapshotReplayPair = { authenticated: SnapshotReplayView; publicSafe: SnapshotReplayView }; const SIGNAL: Record = { ready: "ready", @@ -58,6 +62,7 @@ interface AgentRun { created_at: string; summary?: string; recommendations?: string[]; + snapshotReplays: SnapshotReplayPair[]; } type AgentRunBundleResponse = { @@ -81,11 +86,13 @@ type AgentRunBundle = { actionType: string; targetRepoFullName?: string | null; recommendation?: string | null; + payload?: Record | undefined; }>; contextSnapshots: Array<{ scoringModelId?: string | null; decisionPackVersion?: string | null; freshnessWarnings?: string[]; + payload?: Record | undefined; }>; summary: string; }; @@ -386,6 +393,24 @@ function mapAgentRunBundle(bundle: AgentRunBundle): AgentRun { bundle.contextSnapshots[0]?.scoringModelId ?? bundle.contextSnapshots[0]?.decisionPackVersion ?? "live"; + const counterfactuals = bundle.contextSnapshots.flatMap((snapshot) => { + const reasons = snapshot.payload?.counterfactualReasons; + return Array.isArray(reasons) ? reasons : []; + }); + const snapshotReplays = bundle.actions + .map((action) => action.payload?.recommendationSnapshot) + .filter( + (snapshot): snapshot is Record => + typeof snapshot === "object" && snapshot !== null && !Array.isArray(snapshot), + ) + .map((snapshot) => ({ + authenticated: buildSnapshotReplayView({ + snapshot, + counterfactuals, + viewer: "authenticated", + }), + publicSafe: buildSnapshotReplayView({ snapshot, counterfactuals, viewer: "public" }), + })); return { id: bundle.run.id, source: bundle.run.surface === "github_comment" ? "github-command" : bundle.run.surface, @@ -403,6 +428,7 @@ function mapAgentRunBundle(bundle: AgentRunBundle): AgentRun { created_at: bundle.run.createdAt ?? bundle.run.updatedAt ?? new Date().toISOString(), summary: bundle.summary, recommendations: bundle.actions.map((action) => action.recommendation).filter(isString), + snapshotReplays, }; } @@ -778,6 +804,24 @@ function DrawerSurface({ ))} + + {run.snapshotReplays.length > 0 && ( +
+
+ Snapshot replay +
+ {run.snapshotReplays.map((replay) => ( + + ))} +
+ )}