diff --git a/.gitignore b/.gitignore index 0f91705..aaeb200 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ out coverage *.tsbuildinfo clickfuse-fixture.json +.clickfuse-data/ diff --git a/app/actions.ts b/app/actions.ts index 885380b..3237a00 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -2,6 +2,8 @@ import { auth } from "@trigger.dev/sdk"; import { chat } from "@trigger.dev/sdk/ai"; +import { listIncidentCases, saveIncidentCaseFromVerdict } from "../src/lib/incident-cases"; +import type { IncidentBoard } from "../src/lib/types"; import type { incidentAgent } from "../trigger/incident-agent"; export const startChatSession = chat.createStartSessionAction("incident-agent"); @@ -19,3 +21,11 @@ export async function mintChatAccessToken(chatId: string) { expirationTime: "1h" }); } + +export async function listPromotedIncidents() { + return listIncidentCases(); +} + +export async function promoteVerdictToIncident(board: IncidentBoard) { + return saveIncidentCaseFromVerdict(board); +} diff --git a/app/page.tsx b/app/page.tsx index d48ddaa..44cafa1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,10 +2,10 @@ import { useChat } from "@ai-sdk/react"; import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; -import { FormEvent, useMemo, useState } from "react"; -import { mintChatAccessToken, startChatSession } from "./actions"; +import { FormEvent, useEffect, useMemo, useState, useTransition } from "react"; +import { listPromotedIncidents, mintChatAccessToken, promoteVerdictToIncident, startChatSession } from "./actions"; import { buildIncidentBoard } from "../src/lib/investigation"; -import type { IncidentBoard } from "../src/lib/types"; +import type { IncidentBoard, IncidentCase } from "../src/lib/types"; import type { ServiceName } from "../src/lib/types"; import type { incidentAgent } from "../trigger/incident-agent"; @@ -14,6 +14,9 @@ export default function Home() { const [question, setQuestion] = useState("Why did checkout latency spike after the 14:32 deploy?"); const [selectedService, setSelectedService] = useState("all"); const [openEvidence, setOpenEvidence] = useState("timeline"); + const [incidentCases, setIncidentCases] = useState([]); + const [promoteMessage, setPromoteMessage] = useState(null); + const [isPromoting, startPromoteTransition] = useTransition(); const fixtureBoard = useMemo(() => buildIncidentBoard(), []); const liveMode = process.env.NEXT_PUBLIC_TRIGGER_CHAT_ENABLED === "true"; const transport = useTriggerChatTransport({ @@ -27,6 +30,10 @@ export default function Home() { [fixtureBoard, liveMode, messages] ); + useEffect(() => { + void listPromotedIncidents().then(setIncidentCases).catch(() => setIncidentCases([])); + }, []); + function submit(event: FormEvent) { event.preventDefault(); setSubmitted(true); @@ -35,6 +42,22 @@ export default function Home() { } } + function promoteCurrentVerdict() { + setPromoteMessage(null); + startPromoteTransition(() => { + void promoteVerdictToIncident(board) + .then((incident) => { + setIncidentCases((existing) => [incident, ...existing].slice(0, 20)); + setPromoteMessage(`${incident.id} opened and linked to the proof board.`); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : "unknown persistence error"; + setPromoteMessage(`Could not promote verdict: ${message}`); + }); + }); + } + + return (
@@ -72,6 +95,10 @@ export default function Home() { setSelectedService={setSelectedService} openEvidence={openEvidence} setOpenEvidence={setOpenEvidence} + incidentCases={incidentCases} + promoteMessage={promoteMessage} + isPromoting={isPromoting} + onPromote={promoteCurrentVerdict} /> )}
@@ -131,13 +158,21 @@ function IncidentBoard({ selectedService, setSelectedService, openEvidence, - setOpenEvidence + setOpenEvidence, + incidentCases, + promoteMessage, + isPromoting, + onPromote }: { board: ReturnType; selectedService: ServiceName | "all"; setSelectedService: (service: ServiceName | "all") => void; openEvidence: string | null; setOpenEvidence: (id: string | null) => void; + incidentCases: IncidentCase[]; + promoteMessage: string | null; + isPromoting: boolean; + onPromote: () => void; }) { const selected = selectedService === "all" ? null : selectedService; const timeline = selected ? board.timeline.points.filter((p) => p.service === selected) : board.timeline.points; @@ -181,6 +216,13 @@ function IncidentBoard({ open={openEvidence === "verdict"} onToggle={() => setOpenEvidence(openEvidence === "verdict" ? null : "verdict")} /> + ); } @@ -401,6 +443,75 @@ function VerdictCard({ ); } +function IncidentCaseCard({ + board, + incidentCases, + promoteMessage, + isPromoting, + onPromote +}: { + board: ReturnType; + incidentCases: IncidentCase[]; + promoteMessage: string | null; + isPromoting: boolean; + onPromote: () => void; +}) { + const latest = incidentCases[0]; + const evidenceIds = latest?.linkedAnalytics.evidenceTaskIds ?? [ + board.timeline.evidence.taskId, + board.diff.evidence.taskId, + board.verdict.evidence.taskId + ].filter((taskId): taskId is string => Boolean(taskId)); + + return ( +
+ +
+
+

+ Promote the analytical verdict into a mutable incident record: status, owner, action items, and links back to + the ClickHouse evidence that produced the answer. +

+ + {promoteMessage &&

{promoteMessage}

} +
+
+ {latest ? ( + <> +
+ {latest.id} + {latest.status} +
+

{latest.title}

+
+
Assignee
{latest.assignee}
+
Created
{new Date(latest.createdAt).toLocaleString()}
+
Confidence
{Math.round(latest.confidence * 100)}%
+
Burn rate
{latest.linkedAnalytics.burnRate}x
+
+
    + {latest.actionItems.map((item) =>
  1. {item}
  2. )} +
+ + ) : ( + <> +
+ Draft from current verdict + not opened +
+

{board.verdict.rootCause}

+

{board.verdict.recommendedAction}

+ + )} +

Linked analytics: {evidenceIds.join(" · ")}

+
+
+
+ ); +} + function CardHeader({ title, label, tone }: { title: string; label: string; tone: "red" | "amber" | "green" }) { return (
diff --git a/app/styles.css b/app/styles.css index d338e8f..33e9441 100644 --- a/app/styles.css +++ b/app/styles.css @@ -93,7 +93,7 @@ h1 { padding: 16px 18px; } -.prompt button, .evidence button, .pill { +.prompt button, .evidence button, .pill, .promote-button { border: 0; border-radius: 14px; background: #2563eb; @@ -102,6 +102,11 @@ h1 { cursor: pointer; } +.promote-button:disabled { + cursor: wait; + opacity: 0.7; +} + .agent-status { margin: -10px 0 22px; color: var(--muted); @@ -281,6 +286,78 @@ h1 { color: #fecaca; } +.incident-case-card { + border-color: rgba(34, 197, 94, 0.32); +} + +.incident-case-grid { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); + gap: 18px; + align-items: start; +} + +.promote-message { + color: #bbf7d0; +} + +.incident-case { + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(7, 17, 31, 0.74); + padding: 16px; +} + +.case-topline { + display: flex; + justify-content: space-between; + gap: 14px; + color: #bbf7d0; +} + +.case-topline span { + border-radius: 999px; + background: rgba(34, 197, 94, 0.16); + padding: 4px 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 12px; +} + +.incident-case h3 { + margin: 12px 0; +} + +.incident-case dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0 0 12px; +} + +.incident-case dt { + color: var(--muted); + font-size: 12px; +} + +.incident-case dd { + margin: 2px 0 0; +} + +.incident-case ol { + margin: 0; + padding-left: 20px; + color: #dbeafe; +} + +.case-links { + border-top: 1px solid var(--line); + color: var(--muted); + margin: 14px 0 0; + padding-top: 12px; + font-size: 13px; +} + .evidence { margin-top: 16px; } @@ -335,6 +412,7 @@ pre { .hero, .prompt { flex-direction: column; } .board { grid-template-columns: 1fr; } .wide { grid-column: auto; } + .incident-case-grid { grid-template-columns: 1fr; } .heat-row { grid-template-columns: 1fr repeat(6, 24px); } .bar-row { grid-template-columns: 1fr; } } diff --git a/scripts/smoke.ts b/scripts/smoke.ts index a84a884..b9127b0 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -1,4 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { incidentCaseFromVerdict } from "../src/lib/incident-cases.ts"; import { buildIncidentBoard, smokeMetrics } from "../src/lib/investigation.ts"; import { hasClickHouseEnv } from "../src/lib/clickhouse.ts"; @@ -78,6 +80,20 @@ if (metrics.errorBudget.consumedPct >= 30 && metrics.errorBudget.consumedPct <= fail("error budget", "budget values are not demo-ready"); } +const incidentCase = incidentCaseFromVerdict(board, new Date("2026-07-22T15:05:00.000Z")); +if ( + incidentCase.status === "open" && + incidentCase.assignee === "on-call-sre" && + incidentCase.rootCause === board.verdict.rootCause && + incidentCase.actionItems.length >= 3 && + incidentCase.linkedAnalytics.topSuspect === "payment-service" && + incidentCase.linkedAnalytics.evidenceTaskIds.includes("generate-verdict") +) { + pass("incident case", "verdict can be promoted into an operational incident record linked to analytics evidence"); +} else { + fail("incident case", "promoted incident case is missing status, owner, action items or analytics links"); +} + const schema = existsSync("clickhouse/schema.sql") ? readFileSync("clickhouse/schema.sql", "utf8") : ""; const agentSource = existsSync("trigger/incident-agent.ts") ? readFileSync("trigger/incident-agent.ts", "utf8") : ""; const liveClickHouseSource = existsSync("src/lib/live-clickhouse.ts") ? readFileSync("src/lib/live-clickhouse.ts", "utf8") : ""; @@ -121,11 +137,11 @@ if (hasClickHouseEnv()) { warn("query_log", "fixture mode: run the app once against ClickHouse before recording to populate system.query_log"); } -const forbiddenTracked = [".env", ".env.local"].filter((file) => existsSync(file)); +const forbiddenTracked = trackedFiles([".env", ".env.local"]); if (forbiddenTracked.length === 0) { - pass("secrets", "no local env files found in repo checkout"); + pass("secrets", "no secret-bearing env files are tracked or staged"); } else { - fail("secrets", `secret-bearing files present: ${forbiddenTracked.join(", ")}`); + fail("secrets", `secret-bearing files are tracked or staged: ${forbiddenTracked.join(", ")}`); } console.log("Smoke checks\n"); @@ -147,3 +163,12 @@ if (warnings.length > 0) { } else { console.log("Result: smoke checks passed"); } + +function trackedFiles(files: string[]) { + try { + const output = execFileSync("git", ["ls-files", "--cached", "--", ...files], { encoding: "utf8" }); + return output.split("\n").filter(Boolean); + } catch { + return files.filter((file) => existsSync(file)); + } +} diff --git a/src/lib/incident-cases.ts b/src/lib/incident-cases.ts new file mode 100644 index 0000000..d030378 --- /dev/null +++ b/src/lib/incident-cases.ts @@ -0,0 +1,81 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { IncidentBoard, IncidentCase } from "./types.ts"; + +const storePath = join(process.cwd(), ".clickfuse-data", "incident-cases.json"); + +export function incidentCaseFromVerdict(board: IncidentBoard, now = new Date()): IncidentCase { + const createdAt = now.toISOString(); + const topSuspect = board.suspects.suspects[0]?.service ?? board.deploy.service; + const actionItems = [ + board.verdict.recommendedAction, + `Page ${board.deploy.service} owner and attach evidence from ${board.verdict.evidence.taskId}.`, + `Watch checkout p95 and payment-service spans until burn rate falls below 1x.` + ]; + + return { + id: `INC-${createdAt.slice(0, 10).replaceAll("-", "")}-${createdAt.slice(11, 19).replaceAll(":", "")}`, + title: `Checkout latency spike after ${board.deploy.service} ${board.deploy.version}`, + status: "open", + assignee: "on-call-sre", + createdAt, + updatedAt: createdAt, + source: "verdict-promotion", + rootCause: board.verdict.rootCause, + confidence: board.verdict.confidence, + recommendedAction: board.verdict.recommendedAction, + actionItems, + linkedAnalytics: { + deployService: board.deploy.service, + deployedAt: board.deploy.deployedAt, + deployVersion: board.deploy.version, + topSuspect, + errorBudgetConsumedPct: board.errorBudget.consumedPct, + burnRate: board.errorBudget.burnRate, + evidenceTaskIds: [ + board.timeline.evidence.taskId, + board.heatmap.evidence.taskId, + board.diff.evidence.taskId, + board.suspects.evidence.taskId, + board.errorBudget.evidence.taskId, + board.verdict.evidence.taskId + ].filter((taskId): taskId is string => Boolean(taskId)) + } + }; +} + +export async function listIncidentCases(): Promise { + try { + const raw = await readFile(storePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed.filter(isIncidentCase) : []; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return []; + throw error; + } +} + +export async function saveIncidentCaseFromVerdict(board: IncidentBoard): Promise { + const incident = incidentCaseFromVerdict(board); + const existing = await listIncidentCases(); + const next = [incident, ...existing].slice(0, 20); + await mkdir(dirname(storePath), { recursive: true }); + await writeFile(storePath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + return incident; +} + +function isIncidentCase(value: unknown): value is IncidentCase { + return Boolean( + value && + typeof value === "object" && + "id" in value && + typeof value.id === "string" && + "status" in value && + value.status === "open" && + "rootCause" in value && + typeof value.rootCause === "string" && + "linkedAnalytics" in value && + value.linkedAnalytics && + typeof value.linkedAnalytics === "object" + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0c9a71e..e3e4ad5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -76,3 +76,26 @@ export type IncidentBoard = { evidence: Evidence; }; }; + +export type IncidentCase = { + id: string; + title: string; + status: "open" | "acknowledged" | "mitigating" | "resolved"; + assignee: string; + createdAt: string; + updatedAt: string; + source: "verdict-promotion"; + rootCause: string; + confidence: number; + recommendedAction: string; + actionItems: string[]; + linkedAnalytics: { + deployService: ServiceName; + deployedAt: string; + deployVersion: string; + topSuspect: ServiceName; + errorBudgetConsumedPct: number; + burnRate: number; + evidenceTaskIds: string[]; + }; +};