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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ out
coverage
*.tsbuildinfo
clickfuse-fixture.json
.clickfuse-data/
10 changes: 10 additions & 0 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof incidentAgent>("incident-agent");
Expand All @@ -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);
Comment on lines +29 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not persist client-claimed verdict evidence as trusted incident data.

This action accepts the complete board from the browser, so callers can forge root cause, confidence, service, and evidence IDs before persistence. Derive the board from trusted server-side data, or validate the payload and verify its evidence against the server-side source before saving.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.ts` around lines 29 - 30, Update promoteVerdictToIncident to stop
persisting the browser-provided IncidentBoard as trusted data. Reconstruct the
board from trusted server-side records, or validate the submitted fields and
verify all evidence IDs against the authoritative server-side source before
calling saveIncidentCaseFromVerdict.

}
119 changes: 115 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<ServiceName | "all">("all");
const [openEvidence, setOpenEvidence] = useState<string | null>("timeline");
const [incidentCases, setIncidentCases] = useState<IncidentCase[]>([]);
const [promoteMessage, setPromoteMessage] = useState<string | null>(null);
const [isPromoting, startPromoteTransition] = useTransition();
const fixtureBoard = useMemo(() => buildIncidentBoard(), []);
const liveMode = process.env.NEXT_PUBLIC_TRIGGER_CHAT_ENABLED === "true";
const transport = useTriggerChatTransport<typeof incidentAgent>({
Expand All @@ -27,6 +30,10 @@ export default function Home() {
[fixtureBoard, liveMode, messages]
);

useEffect(() => {
void listPromotedIncidents().then(setIncidentCases).catch(() => setIncidentCases([]));
}, []);

function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitted(true);
Expand All @@ -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}`);
});
});
Comment on lines +47 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate app/page.tsx and relevant files =="
fd -a 'page\.tsx$' . | sed 's#^\./##' | head -50
echo

if [ -f app/page.tsx ]; then
  echo "== app/page.tsx outline =="
  ast-grep outline app/page.tsx --view expanded || true
  echo
  echo "== app/page.tsx lines 1-140 =="
  sed -n '1,140p' app/page.tsx | nl -ba
fi

echo
echo "== search for startPromoteTransition / promoteVerdictToIncident =="
rg -n "startPromoteTransition|promoteVerdictToIncident|setPromoting|isPromoting|startTransition" -S .

Repository: knarayanareddy/Clickfuse

Length of output: 1080


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,140p' app/page.tsx
echo
sed -n '420,514p' app/page.tsx
echo
printf '\n== search relevant symbols ==\n'
rg -n "startPromoteTransition|promoteVerdictToIncident|setPromoting|isPromoting|startTransition|async function|action=" -S .

Repository: knarayanareddy/Clickfuse

Length of output: 11190


🌐 Web query:

React 19 useTransition callback async function startTransition promise behavior

💡 Result:

In React 19, you can pass an asynchronous function (known as an "Action") to the startTransition callback [1][2]. This enables React to automatically track the pending state of asynchronous operations, which is particularly useful for handling forms, errors, and data updates without blocking the UI [1][3]. Key behaviors for async startTransition in React 19: 1. Pending State Tracking: When an async function is passed to the startTransition function (provided by useTransition), the isPending boolean is set to true immediately [1][4]. It remains true until the asynchronous operation completes and all associated state updates have been processed [1][4]. 2. Automatic grouping: React includes the asynchronous function execution and subsequent state updates within the Transition [5][6]. This keeps the UI responsive while the background work (such as fetch requests) is ongoing [1][2]. 3. Crucial Distinction between useTransition and standalone startTransition: - When using useTransition, state updates occurring after an await within the async action are automatically included in the transition [7]. - When using the standalone startTransition function (outside of components or where isPending is not needed), state updates performed after an await are not automatically included in the transition [6]. In this case, you must wrap those subsequent state updates in an additional startTransition call to ensure they remain part of the transition [5][6][7]. 4. Limitation: The current implementation of async transitions relies on grouping updates that occur while the async action is executing [4]. Because of the lack of a browser-native Async Context API, there is a known limitation where updates occurring after an await in a standalone startTransition require explicit re-wrapping [5][6][4]. This is documented as a known behavior that may be improved in future versions [5][6].

Citations:


Return the promotion Promise from the transition.

The void call detaches the request, so the transition can finish before promoteVerdictToIncident(board) waits on persistence. Make the transition callback async and await the server action so isPromoting stays true for the whole promotion.

Proposed fix
-    startPromoteTransition(() => {
-      void promoteVerdictToIncident(board)
-        .then((incident) => {
+    startPromoteTransition(async () => {
+      try {
+        const incident = await promoteVerdictToIncident(board);
           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}`);
-        });
+      } catch (error: unknown) {
+        const message = error instanceof Error ? error.message : "unknown persistence error";
+        setPromoteMessage(`Could not promote verdict: ${message}`);
+      }
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}`);
});
});
startPromoteTransition(async () => {
try {
const incident = await promoteVerdictToIncident(board);
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}`);
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/page.tsx` around lines 47 - 57, Update the startPromoteTransition
callback around promoteVerdictToIncident to be async and await the promotion
Promise, while preserving the existing success and error handling so the
transition remains active until persistence completes.

}


return (
<main className="shell">
<section className="hero">
Expand Down Expand Up @@ -72,6 +95,10 @@ export default function Home() {
setSelectedService={setSelectedService}
openEvidence={openEvidence}
setOpenEvidence={setOpenEvidence}
incidentCases={incidentCases}
promoteMessage={promoteMessage}
isPromoting={isPromoting}
onPromote={promoteCurrentVerdict}
/>
)}
</main>
Expand Down Expand Up @@ -131,13 +158,21 @@ function IncidentBoard({
selectedService,
setSelectedService,
openEvidence,
setOpenEvidence
setOpenEvidence,
incidentCases,
promoteMessage,
isPromoting,
onPromote
}: {
board: ReturnType<typeof buildIncidentBoard>;
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;
Expand Down Expand Up @@ -181,6 +216,13 @@ function IncidentBoard({
open={openEvidence === "verdict"}
onToggle={() => setOpenEvidence(openEvidence === "verdict" ? null : "verdict")}
/>
<IncidentCaseCard
board={board}
incidentCases={incidentCases}
promoteMessage={promoteMessage}
isPromoting={isPromoting}
onPromote={onPromote}
/>
</section>
);
}
Expand Down Expand Up @@ -401,6 +443,75 @@ function VerdictCard({
);
}

function IncidentCaseCard({
board,
incidentCases,
promoteMessage,
isPromoting,
onPromote
}: {
board: ReturnType<typeof buildIncidentBoard>;
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 (
<article className="card wide incident-case-card">
<CardHeader title="Operational incident case" label="OLTP + OLAP handoff" tone="green" />
<div className="incident-case-grid">
<div>
<p className="caption">
Promote the analytical verdict into a mutable incident record: status, owner, action items, and links back to
the ClickHouse evidence that produced the answer.
</p>
<button className="promote-button" type="button" onClick={onPromote} disabled={isPromoting}>
{isPromoting ? "Opening incident…" : "Promote verdict to incident"}
</button>
{promoteMessage && <p className="promote-message">{promoteMessage}</p>}
</div>
<div className="incident-case">
{latest ? (
<>
<div className="case-topline">
<strong>{latest.id}</strong>
<span>{latest.status}</span>
</div>
<h3>{latest.title}</h3>
<dl>
<div><dt>Assignee</dt><dd>{latest.assignee}</dd></div>
<div><dt>Created</dt><dd>{new Date(latest.createdAt).toLocaleString()}</dd></div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked files around app/page.tsx:"
git ls-files | rg '(^|/)app/page\.tsx$|next\.config|package\.json'

echo
echo "Relevant lines in app/page.tsx:"
if [ -f app/page.tsx ]; then
  wc -l app/page.tsx
  sed -n '450,510p' app/page.tsx | nl -ba -v450
fi

echo
echo "Search for other createdAt/date formatting usages:"
rg -n "toLocaleString\(|Date\(|createdAt|new Date" app package.json next.config.* 2>/dev/null || true

echo
echo "Package framework hints:"
if [ -f package.json ]; then
  node - <<'JS'
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
console.log(JSON.stringify({dependencies: pkg.dependencies || {}, devDependencies: pkg.devDependencies || {}, scripts: pkg.scripts || {}}, null, 2));
JS
fi

echo
echo "Relevant Next config snippets if present:"
for f in $(git ls-files | rg '(^|/)next\.config\.(js|mjs|ts|cjs)$'); do
  echo "--- $f"
  sed -n '1,240p' "$f"
done

Repository: knarayanareddy/Clickfuse

Length of output: 329


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant app/page.tsx block:"
sed -n '450,510p' app/page.tsx | cat -n

echo
echo "Search for date formatting/usages:"
rg -n "toLocaleString\(|toLocaleDateString\(|toLocaleTimeString\(|Date\(|createdAt|new Date" app

echo
echo "Framework/config/package hints:"
for f in package.json next.config.ts next.config.js; do
  if [ -f "$f" ]; then
    echo "--- $f"
    sed -n '1,220p' "$f"
  fi
done

echo
echo "App structure/client hints:"
rg -n '"use client"|React\.useEffect|useEffect|useState|Suspense|hydrate' app || true

Repository: knarayanareddy/Clickfuse

Length of output: 5243


🌐 Web query:

Next.js Next.js Server Components client components use "use client" hydration client side rendering

💡 Result:

In Next.js, Server Components and Client Components are used together to balance server-side rendering performance with client-side interactivity [1][2][3]. Server Components (the default) are rendered on the server, resulting in zero client-side JavaScript for that component's logic, which reduces bundle size and improves performance [1][4][3]. They are ideal for data fetching, accessing databases, and rendering static content [2][3][5]. Client Components are used when you need interactivity or browser-specific capabilities [2][3]. You opt into Client Components by adding the "use client" directive at the very top of a file [1][2][6]. This directive does not mean the component renders only on the client; rather, it defines a boundary [2][7]. Client Components are still pre-rendered to HTML on the server to provide a fast initial page load [8][9]. Once the HTML reaches the browser, the component undergoes hydration [8][10][9]. Hydration is the process by which React attaches event listeners and state to the server-rendered HTML, transforming it into an interactive interface [8][9]. Key best practices include: - Start with Server Components by default [6][11][5]. - Keep the "use client" boundary as deep as possible to minimize the JavaScript sent to the client [3][11][5]. - Use Client Components only when necessary, such as for React hooks (e.g., useState, useEffect), event handlers (e.g., onClick), or browser APIs (e.g., window, localStorage) [2][7][5]. - Pass Server Components as children or props to Client Components to maintain server-side rendering for non-interactive parts of your UI [1][6][11]. By strategically using these components, you ensure that only the necessary parts of your application are hydrated in the browser, keeping the overall application fast and responsive [3][10][5].

Citations:


Use a deterministic date format in the rendered HTML.

app/page.tsx is a Client Component, so this date is rendered both during server-side rendering and during hydration. toLocaleString() can use different locale/timezone defaults on the server and browser; pass a fixed locale/timezone, or store/display an ISO-formatted value instead.

🧰 Tools
🪛 React Doctor (0.7.6)

[error] 490-490: This can cause a hydration mismatch because toLocaleString() formats with the server's locale and timezone during server rendering but the user's in the browser. Format it in a post-mount useEffect, or pass an explicit locale and timeZone.

Format locale/timezone-dependent values in a post-mount useEffect + state, or pass an explicit locale and timeZone so the server and the browser render the same text. Only runs on SSR-capable projects.

(no-locale-format-in-render)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/page.tsx` at line 490, Update the date rendering in the Created field
near latest.createdAt to use a deterministic representation across server
rendering and hydration by supplying an explicit locale and timezone to the
formatter, or rendering an ISO-formatted value. Keep the existing createdAt
value and display context unchanged.

Source: Linters/SAST tools

<div><dt>Confidence</dt><dd>{Math.round(latest.confidence * 100)}%</dd></div>
<div><dt>Burn rate</dt><dd>{latest.linkedAnalytics.burnRate}x</dd></div>
</dl>
<ol>
{latest.actionItems.map((item) => <li key={item}>{item}</li>)}
</ol>
</>
) : (
<>
<div className="case-topline">
<strong>Draft from current verdict</strong>
<span>not opened</span>
</div>
<h3>{board.verdict.rootCause}</h3>
<p className="caption">{board.verdict.recommendedAction}</p>
</>
)}
<p className="case-links">Linked analytics: {evidenceIds.join(" · ")}</p>
</div>
</div>
</article>
);
}

function CardHeader({ title, label, tone }: { title: string; label: string; tone: "red" | "amber" | "green" }) {
return (
<header className="card-header">
Expand Down
80 changes: 79 additions & 1 deletion app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -102,6 +102,11 @@ h1 {
cursor: pointer;
}

.promote-button:disabled {
cursor: wait;
opacity: 0.7;
}

.agent-status {
margin: -10px 0 22px;
color: var(--muted);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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; }
}
31 changes: 28 additions & 3 deletions scripts/smoke.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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") : "";
Expand Down Expand Up @@ -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");
Expand All @@ -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));
}
}
Loading