Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,14 @@ import { AgentActivity } from "./components/AgentActivity.js";
import { ApprovalQueue } from "./components/ApprovalQueue.js";
import { ErrorBanner } from "./components/ErrorBanner.js";
import { useScanSession } from "./hooks/useScanSession.js";
import {
classifyClientError,
recoveryFor,
type ProductErrorKind,
} from "./lib/errors.js";
import { classifyClientError, recoveryFor, type ProductErrorKind } from "./lib/errors.js";

export function App() {
const session = useScanSession();
const costs = session.activity.costs;
const capped = session.activity.status === "cost_capped";
const partial = session.activity.status === "partial";
const showBanner =
Boolean(session.error) ||
capped ||
partial ||
Boolean(session.activity.error);
const showBanner = Boolean(session.error) || capped || partial || Boolean(session.activity.error);

const kind = (session.activity.errorKind ??
(capped
Expand All @@ -37,17 +29,14 @@ export function App() {
? "Some connectors failed; results below are incomplete."
: "");

const recovery =
session.activity.recovery ?? (kind ? recoveryFor(kind) : null);
const recovery = session.activity.recovery ?? (kind ? recoveryFor(kind) : null);

return (
<div className="flex h-screen flex-col overflow-hidden">
<header className="flex shrink-0 items-center justify-between border-b border-[var(--color-line)] bg-[var(--color-panel)] px-5 py-3">
<div className="flex items-baseline gap-3">
<span className="text-[15px] font-semibold tracking-tight">Keyring</span>
<span className="text-[12px] text-[var(--color-faint)]">
Access governance
</span>
<span className="text-[12px] text-[var(--color-faint)]">Access governance</span>
</div>
<div className="font-mono text-[11px] text-[var(--color-faint)]">
{session.activity.scanId
Expand Down Expand Up @@ -79,6 +68,7 @@ export function App() {
/>
<ApprovalQueue
cards={session.cards}
systemIds={Object.keys(session.activity.subagents)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Stale refresh corrupts headline 🐞 Bug ≡ Correctness

A card refresh started by the previous scan can resolve after a new scan begins and overwrite the
cleared queue, while the new headline counts those old cards against the new scan's subagents.
Because that stale response can also restore status completed, users can see a completed summary
containing mismatched grant and system counts during the new scan.
Agent Prompt
## Issue description
Card fetches from a prior scan can update state after a new scan starts, causing the summary to combine stale cards with the current scan's systems and status.

## Issue Context
`refreshCards` dispatches results without identifying their scan, and the reducer accepts every `cards` action. Track the request's scan ID or a session generation and ignore results that no longer belong to the active scan.

## Fix Focus Areas
- apps/web/src/hooks/useScanSession.ts[32-39]
- apps/web/src/hooks/useScanSession.ts[132-152]
- apps/web/src/hooks/useScanSession.ts[469-509]
- apps/web/src/App.tsx[69-73]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

scanId={session.activity.scanId}
scanStatus={session.activity.status}
onCardUpdated={session.updateCard}
Expand Down
36 changes: 35 additions & 1 deletion apps/web/src/components/ApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@ import { useEffect, useMemo, useState } from "react";

import { postDecision } from "../api/client.js";
import type { ApiCard } from "../api/types.js";
import { isUnattributed, sortCards } from "../lib/format.js";
import { countScanSummary, isUnattributed, scanSummaryText, sortCards } from "../lib/format.js";
import { ApprovalCardView } from "./ApprovalCardView.js";
import { ExecutePanel } from "./ExecutePanel.js";
import { HoldDialog } from "./HoldDialog.js";

export function ApprovalQueue({
cards,
systemIds,
scanId,
scanStatus,
onCardUpdated,
}: {
cards: ApiCard[];
systemIds: string[];
scanId: string | null;
scanStatus: string;
onCardUpdated: (card: ApiCard) => void;
Expand Down Expand Up @@ -121,6 +123,7 @@ export function ApprovalQueue({
const approved = ordered.filter((c) => c.status === "approved");
const pendingCount = ordered.filter((c) => c.status === "pending").length;
const heldCount = ordered.filter((c) => c.status === "held").length;
const summary = countScanSummary(cards, systemIds);

return (
<section className="flex h-full min-h-0 flex-col bg-[var(--color-surface)]">
Expand Down Expand Up @@ -193,6 +196,37 @@ export function ApprovalQueue({
</header>

<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{scanStatus === "completed" ? (
<div
aria-label={scanSummaryText(summary)}
className="mx-auto mb-5 max-w-3xl border border-[var(--color-line)] bg-[var(--color-panel)] px-4 py-3"
>
<p className="text-[13px] leading-relaxed text-[var(--color-ink-2)]">
<span className="font-semibold text-[var(--color-ink)]">
{summary.grants} grant{summary.grants === 1 ? "" : "s"} across {summary.systems}{" "}
system{summary.systems === 1 ? "" : "s"}.
</span>
{summary.unattributed > 0 ? (
<>
{" "}
<span className="font-semibold text-[var(--color-irrev)]">
{summary.unattributed} we cannot attribute to anyone.
</span>
</>
) : null}
{summary.overYearIdle > 0 ? (
<> {summary.overYearIdle} not used in over a year.</>
) : null}
{summary.irreversible > 0 ? (
<>
{" "}
{summary.irreversible} {summary.irreversible === 1 ? "is" : "are"} irreversible to
revoke.
</>
) : null}
</p>
</div>
) : null}
{ordered.length === 0 ? (
<EmptyState scanStatus={scanStatus} />
) : (
Expand Down
46 changes: 45 additions & 1 deletion apps/web/src/lib/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";

import type { ApiCard } from "../api/types.js";
import { isUnattributed, sortCards, staleness } from "../lib/format.js";
import {
countScanSummary,
isUnattributed,
scanSummaryText,
sortCards,
staleness,
} from "../lib/format.js";

function card(partial: Partial<ApiCard> & Pick<ApiCard, "id">): ApiCard {
return {
Expand Down Expand Up @@ -62,4 +68,42 @@ describe("format helpers", () => {
it("flags missing lastUsedAt as unknown staleness", () => {
expect(staleness(null).level).toBe("unknown");
});

it("counts summary findings from cards and connected systems", () => {
const counts = countScanSummary(
[
card({ id: "unknown", attribution: { confidence: "speculative", reasoning: "unknown" } }),
card({
id: "old",
irreversible: true,
grant: {
...card({ id: "old-base" }).grant,
lastUsedAt: "2024-01-01T00:00:00.000Z",
},
}),
],
["github", "slack", "aws"],
new Date("2026-06-01T00:00:00.000Z"),
);

expect(counts).toEqual({
grants: 2,
systems: 3,
unattributed: 1,
overYearIdle: 1,
irreversible: 1,
});
expect(scanSummaryText(counts)).toBe(
"2 grants across 3 systems. 1 we cannot attribute to anyone. 1 not used in over a year. 1 is irreversible to revoke.",
);
});

it("omits zero-category clauses from the headline", () => {
const counts = countScanSummary([card({ id: "one" })], ["github"]);

expect(counts.unattributed).toBe(0);
expect(counts.overYearIdle).toBe(0);
expect(counts.irreversible).toBe(0);
expect(scanSummaryText(counts)).toBe("1 grant across 1 system.");
});
});
40 changes: 39 additions & 1 deletion apps/web/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,41 @@ import type { ApiCard } from "../api/types.js";

const MS_DAY = 86_400_000;

export interface ScanSummaryCounts {
grants: number;
systems: number;
unattributed: number;
overYearIdle: number;
irreversible: number;
}

export function countScanSummary(
cards: ApiCard[],
systemIds: Iterable<string> = cards.map((card) => card.grant.system),
now = new Date(),
): ScanSummaryCounts {
return {
grants: cards.length,
systems: new Set(systemIds).size,
unattributed: cards.filter(isUnattributed).length,
overYearIdle: cards.filter((card) => staleness(card.grant.lastUsedAt, now).level === "critical")
.length,
irreversible: cards.filter((card) => card.irreversible).length,
};
}

export function scanSummaryText(counts: ScanSummaryCounts): string {
const clauses = [
`${counts.grants} grant${counts.grants === 1 ? "" : "s"} across ${counts.systems} system${counts.systems === 1 ? "" : "s"}.`,
counts.unattributed > 0 ? `${counts.unattributed} we cannot attribute to anyone.` : null,
counts.overYearIdle > 0 ? `${counts.overYearIdle} not used in over a year.` : null,
counts.irreversible > 0
? `${counts.irreversible} ${counts.irreversible === 1 ? "is" : "are"} irreversible to revoke.`
: null,
].filter((clause): clause is string => clause !== null);
return clauses.join(" ");
}

export function principalLabel(card: ApiCard): string {
const ids = card.grant.principal.identifiers;
if (ids.length === 0) return "Unknown principal";
Expand Down Expand Up @@ -30,7 +65,10 @@ export function formatWhen(iso: string | null | undefined): string {
});
}

export function staleness(iso: string | null | undefined, now = new Date()): {
export function staleness(
iso: string | null | undefined,
now = new Date(),
): {
label: string;
level: "ok" | "cool" | "stale" | "critical" | "unknown";
days: number | null;
Expand Down
4 changes: 2 additions & 2 deletions scripts/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ async function main(): Promise<void> {
process.env.KEYRING_PGLITE_PATH = pglitePath;
process.env.KEYRING_SCAN_DRIVER = "replay";
process.env.KEYRING_EXECUTE_DRY_RUN = "1";
process.env.KEYRING_REPLAY_SPEED = process.env.KEYRING_REPLAY_SPEED ?? "20";
process.env.KEYRING_REPLAY_MAX_GAP_MS = process.env.KEYRING_REPLAY_MAX_GAP_MS ?? "5000";
// Clear DATABASE_URL so demo never requires Docker Postgres
delete process.env.DATABASE_URL;

Expand Down Expand Up @@ -154,8 +156,6 @@ async function main(): Promise<void> {
VITE_SCAN_DRIVER: "replay",
VITE_API_BASE_URL: "",
VITE_API_PORT: String(apiPort),
KEYRING_REPLAY_SPEED: process.env.KEYRING_REPLAY_SPEED ?? "20",
KEYRING_REPLAY_MAX_GAP_MS: process.env.KEYRING_REPLAY_MAX_GAP_MS ?? "5000",
},
stdio: ["ignore", "pipe", "pipe"],
},
Expand Down
Loading