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
34 changes: 34 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { AgentActivity } from "./components/AgentActivity.js";
import { ApprovalQueue } from "./components/ApprovalQueue.js";
import { ErrorBanner } from "./components/ErrorBanner.js";
import { GuidedDemoPanel } from "./components/GuidedDemoPanel.js";
import { useScanSession } from "./hooks/useScanSession.js";
import { useGuidedDemo } from "./hooks/useGuidedDemo.js";
import { classifyClientError, recoveryFor, type ProductErrorKind } from "./lib/errors.js";

export function App() {
const session = useScanSession();
const demoMode =
import.meta.env.VITE_DEMO_MODE === "1" ||
import.meta.env.VITE_SCAN_DRIVER === "replay" ||
session.activity.driver === "replay";
const guided = useGuidedDemo({
activity: session.activity,
cards: session.cards,
beginScan: session.beginScan,
updateCard: session.updateCard,
cancelScan: session.cancelScan,
resetDemoScan: session.resetDemoScan,
});
const costs = session.activity.costs;
const capped = session.activity.status === "cost_capped";
const partial = session.activity.status === "partial";
Expand Down Expand Up @@ -44,6 +58,15 @@ export function App() {
: "no active scan"}
{session.activity.driver ? ` · ${session.activity.driver}` : ""}
</div>
{demoMode && !guided.active ? (
<button
type="button"
onClick={() => void guided.run()}
className="border border-[var(--color-ink)] bg-[var(--color-ink)] px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-[var(--color-ink-2)]"
>
Run guided demo
</button>
) : null}
</header>

{showBanner && message ? (
Expand Down Expand Up @@ -72,9 +95,20 @@ export function App() {
scanId={session.activity.scanId}
scanStatus={session.activity.status}
onCardUpdated={session.updateCard}
guidedMode={demoMode && guided.state.phase !== "idle"}
guidedCardId={guided.state.targetCardId}
/>
</div>

{demoMode && guided.state.phase !== "idle" ? (
<GuidedDemoPanel
state={guided.state}
cards={session.cards}
onContinue={guided.continueGate}
onStop={guided.stop}
/>
) : null}

<footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t border-[var(--color-line)] bg-[var(--color-panel)] px-5 py-2 font-mono text-[11px] text-[var(--color-mute)]">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>
Expand Down
111 changes: 101 additions & 10 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import type { ApiCard, ExecuteResult, ScanCostSnapshot, ScanProgressEvent } from "./types.js";
import type {
ApiCard,
AuditRecord,
AuditVerification,
ExecuteResult,
ScanCostSnapshot,
ScanProgressEvent,
} from "./types.js";

const base = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "";

export interface ExecuteResponse {
scanId: string;
dryRun: boolean;
executed: number;
failed: number;
skipped: number;
results: ExecuteResult[];
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
...init,
Expand Down Expand Up @@ -64,31 +80,97 @@ export async function postDecision(
by?: string;
bulk?: boolean;
},
signal?: AbortSignal,
): Promise<{ card: ApiCard; message: string }> {
return request(`/cards/${cardId}/decision`, {
method: "POST",
body: JSON.stringify({ by: "operator", ...body }),
signal,
});
}

export async function resetDemoScan(
scanId: string,
signal?: AbortSignal,
): Promise<{ scanId: string; reset: number }> {
return request(`/scans/${scanId}/demo-reset`, {
method: "POST",
signal,
});
}

export async function executeScan(
scanId: string,
approvedBy = "operator",
dryRun = true,
): Promise<{
scanId: string;
dryRun: boolean;
executed: number;
failed: number;
skipped: number;
results: ExecuteResult[];
}> {
): Promise<ExecuteResponse> {
return request(`/scans/${scanId}/execute`, {
method: "POST",
body: JSON.stringify({ approvedBy, dryRun }),
});
}

export async function executeScanStream(
scanId: string,
approvedBy = "operator",
dryRun = true,
signal?: AbortSignal,
onEvent?: (event: ScanProgressEvent) => void | Promise<void>,
): Promise<ExecuteResponse> {
const res = await fetch(`${base}/scans/${scanId}/execute`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "text/event-stream",
},
body: JSON.stringify({ approvedBy, dryRun }),
signal,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} /scans/${scanId}/execute: ${text}`);
}
if (!res.body) throw new Error("Execute stream returned no body.");

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let summary: ExecuteResponse | null = null;

const consume = async (chunk: string) => {
buffer += chunk;
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() ?? "";
for (const frame of frames) {
const eventName = frame.match(/^event: (.+)$/m)?.[1] ?? "message";
const data = frame.match(/^data: (.+)$/m)?.[1];
if (!data) continue;
const event = JSON.parse(data) as ScanProgressEvent;
if (eventName === "execute.done") {
summary = event as unknown as ExecuteResponse;
} else {
await onEvent?.({ ...event, type: event.type || eventName });
}
}
};

while (true) {
const next = await reader.read();
if (next.done) break;
await consume(decoder.decode(next.value, { stream: true }));
}
await consume(decoder.decode());
if (!summary) throw new Error("Execute stream ended before execute.done.");
return summary;
}

export async function fetchAudit(): Promise<{
records: AuditRecord[];
verification: AuditVerification;
}> {
return request("/audit");
}

/**
* Subscribe to scan SSE. Returns an unsubscribe function.
* Replays snapshot history, then live events.
Expand Down Expand Up @@ -137,8 +219,17 @@ export function subscribeScanStream(
"execute.done",
"subagent.failed",
];
const terminalTypes = new Set([
"scan.completed",
"scan.failed",
"scan.cost_capped",
"scan.partial",
]);
for (const t of types) {
es.addEventListener(t, (e) => forward(t, (e as MessageEvent).data));
es.addEventListener(t, (e) => {
forward(t, (e as MessageEvent).data);
if (terminalTypes.has(t)) es.close();
});
}
es.onmessage = (e) => forward("message", e.data);
es.onerror = () => {
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,23 @@ export interface ExecuteResult {
params: Record<string, unknown>;
};
}

export interface AuditRecord {
id: string;
cardId: string;
action: string;
approvedBy: string;
approvedAt: string;
executedAt: string;
result: "success" | "failed" | "partial";
error?: string | null;
prevHash: string;
hash: string;
}

export interface AuditVerification {
ok: boolean;
count: number;
index?: number;
reason?: string;
}
29 changes: 12 additions & 17 deletions apps/web/src/components/ApprovalCardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function ApprovalCardView({
onApprove,
onHold,
onReject,
actionsDisabled = false,
}: {
card: ApiCard;
selected: boolean;
Expand All @@ -30,13 +31,15 @@ export function ApprovalCardView({
onApprove: () => void;
onHold: () => void;
onReject: () => void;
actionsDisabled?: boolean;
}) {
const stale = staleness(card.grant.lastUsedAt);
const pending = card.status === "pending";
const who = principalLabel(card);

return (
<article
id={`approval-card-${card.id}`}
role="listitem"
tabIndex={0}
onFocus={onFocus}
Expand All @@ -52,7 +55,7 @@ export function ApprovalCardView({
<input
type="checkbox"
checked={checked}
disabled={!pending}
disabled={!pending || actionsDisabled}
onChange={onToggleCheck}
className="h-3.5 w-3.5 accent-[var(--color-ink)]"
aria-label={`Select ${who}`}
Expand All @@ -63,9 +66,7 @@ export function ApprovalCardView({
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-semibold tracking-tight">
{who}
</h3>
<h3 className="truncate text-[14px] font-semibold tracking-tight">{who}</h3>
<ConfidenceBadge confidence={card.attribution.confidence} />
{card.irreversible ? (
<span className="border border-[var(--color-irrev)] bg-[var(--color-irrev-soft)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--color-irrev)]">
Expand Down Expand Up @@ -115,7 +116,10 @@ export function ApprovalCardView({
</div>

<div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1 text-[12px]">
<Meta label="Created" value={formatWhen(card.grant.createdAt ?? card.grant.discoveredAt)} />
<Meta
label="Created"
value={formatWhen(card.grant.createdAt ?? card.grant.discoveredAt)}
/>
<Meta
label="Last used"
value={
Expand Down Expand Up @@ -155,16 +159,13 @@ export function ApprovalCardView({

<ul className="mt-2 space-y-0.5 border-t border-[var(--color-line)] pt-2">
{card.risk.reasons.map((r) => (
<li
key={r}
className="font-mono text-[11px] leading-snug text-[var(--color-mute)]"
>
<li key={r} className="font-mono text-[11px] leading-snug text-[var(--color-mute)]">
{r}
</li>
))}
</ul>

{pending ? (
{pending && !actionsDisabled ? (
<div className="mt-3 flex flex-wrap gap-1.5">
<ActionButton onClick={onApprove} variant="primary">
Approve
Expand All @@ -191,13 +192,7 @@ function ConfidenceBadge({ confidence }: { confidence: string }) {
);
}

function Meta({
label,
value,
}: {
label: string;
value: ReactNode;
}) {
function Meta({ label, value }: { label: string; value: ReactNode }) {
return (
<div>
<span className="text-[10px] uppercase tracking-[0.06em] text-[var(--color-faint)]">
Expand Down
Loading
Loading