From d3cac9a0baca8506a1944026c9a8bc1494efa80d Mon Sep 17 00:00:00 2001 From: Shashank Date: Sun, 29 Mar 2026 20:08:15 +0530 Subject: [PATCH 1/9] feat: The AI backend will now intentionally target and disable specific Server/Database nodes when throwing Regional Failover events - The UI will instantly trap the user with a pulsing red 5-Minute Panic Timer, forcing them to either mitigate the problem before the clock runs out or hit submit - The Canvas will vividly display the broken node (grayed out, marked with a pulsing red warning icon) rendering it dead --- app/api/interview/[id]/hint/route.ts | 7 ++- app/interview/[id]/page.tsx | 3 +- components/canvas/DesignCanvas.tsx | 39 ++++++++++++--- components/interview/ChaosTimer.tsx | 60 ++++++++++++++++++++++++ components/interview/InterviewHeader.tsx | 14 ++++-- src/lib/db/models/InterviewSession.ts | 2 + 6 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 components/interview/ChaosTimer.tsx diff --git a/app/api/interview/[id]/hint/route.ts b/app/api/interview/[id]/hint/route.ts index 14b8b97..4ec7be0 100644 --- a/app/api/interview/[id]/hint/route.ts +++ b/app/api/interview/[id]/hint/route.ts @@ -68,12 +68,15 @@ function generateConstraintChange( impactAreas: ['scalability', 'caching', 'load balancing'], }; } else if ((databaseCount <= 1 || mentionsDisconnected) && difficulty !== 'easy') { + const candidateNodes = nodes.filter((n) => ['SQL', 'Blob', 'Cache', 'Server'].includes(n.type)); + const victim = candidateNodes[Math.floor(Math.random() * candidateNodes.length)]; selected = { type: 'reliability', - title: 'Regional Failover', - description: 'The system must continue serving users during a full regional outage with minimal disruption.', + title: 'Node Failure', + description: victim ? `The ${victim.label || victim.type} component just went offline unexpectedly. The system must continue serving users with minimal disruption.` : 'The system must continue serving users during a full regional outage with minimal disruption.', severity: difficulty === 'hard' ? 'high' : 'moderate', impactAreas: ['availability', 'replication', 'disaster recovery'], + impactedNodeId: victim?.id, }; } else if (!hasQueue && (hasRealtimePrompt || nodeTypes.has('Server') || nodeTypes.has('Function'))) { selected = { diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 65abbf7..47fff56 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -330,7 +330,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { {/* Properties Panel */} diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 92bd355..fcfb5c5 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useRef, useId, useCallback, useEffect, useReducer, MutableRefObject } from 'react'; +import { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; // Color mapping for different component types const COLOR_MAP: Record = { @@ -81,6 +82,8 @@ interface DesignCanvasProps { readOnly?: boolean; /** Live ref to current canvas state — updated on every change */ stateRef?: MutableRefObject; + /** Array of active constraint changes to visually impact the canvas */ + activeConstraints?: IConstraintChange[]; } const MAX_HISTORY = 50; @@ -139,7 +142,8 @@ export function DesignCanvas({ initialConnections = DEFAULT_CONNECTIONS, onSave, readOnly = false, - stateRef + stateRef, + activeConstraints = [] }: DesignCanvasProps) { const arrowId = useId(); const canvasRef = useRef(null); @@ -803,17 +807,34 @@ export function DesignCanvas({ const colors = getColorClasses(node.type); const isSelected = node.id === selectedNodeId; + const isImpacted = activeConstraints.some(c => c.impactedNodeId === node.id && c.status === 'active'); + return (
handleNodeMouseDown(e, node.id)} - onMouseUp={(e) => handleNodeMouseUp(e, node.id)} + onMouseDown={(e) => { + if (isImpacted) { + e.stopPropagation(); + setSelectedNodeId(node.id); + return; + } + handleNodeMouseDown(e, node.id); + }} + onMouseUp={(e) => { + if (isImpacted) { + e.stopPropagation(); + return; + } + handleNodeMouseUp(e, node.id); + }} > {/* Delete button - visible when selected and not readOnly */} {isSelected && !readOnly && ( @@ -829,6 +850,12 @@ export function DesignCanvas({ close )} + + {isImpacted && ( +
+ warning +
+ )} void; +} + +export function ChaosTimer({ introducedAt, timeLimitMs = 300000, onExpired }: ChaosTimerProps) { + const onExpiredRef = useRef(onExpired); + useEffect(() => { + onExpiredRef.current = onExpired; + }, [onExpired]); + + const calculateRemaining = useCallback((): number => { + const start = new Date(introducedAt).getTime(); + const endTime = start + timeLimitMs; + const remaining = Math.max(0, endTime - Date.now()); + return Math.ceil(remaining / 1000); + }, [introducedAt, timeLimitMs]); + + const [secondsRemaining, setSecondsRemaining] = useState(() => calculateRemaining()); + + useEffect(() => { + const remainingInit = calculateRemaining(); + setSecondsRemaining(remainingInit); + + if (remainingInit <= 0) return; + + const interval = setInterval(() => { + const current = calculateRemaining(); + setSecondsRemaining(current); + if (current <= 0) { + clearInterval(interval); + onExpiredRef.current?.(); + } + }, 1000); + + return () => clearInterval(interval); + }, [calculateRemaining]); + + const minutes = Math.floor(secondsRemaining / 60); + const seconds = secondsRemaining % 60; + const formatted = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + const isExpired = secondsRemaining <= 0; + + return ( +
+ warning +
+ Evacuate / Mitigate + + {isExpired ? '00:00' : formatted} + +
+
+ ); +} diff --git a/components/interview/InterviewHeader.tsx b/components/interview/InterviewHeader.tsx index ac26a24..6457357 100644 --- a/components/interview/InterviewHeader.tsx +++ b/components/interview/InterviewHeader.tsx @@ -2,11 +2,13 @@ import Link from 'next/link'; import { InterviewTimer } from './InterviewTimer'; +import { ChaosTimer } from './ChaosTimer'; +import { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; interface InterviewHeaderProps { difficulty: 'easy' | 'medium' | 'hard'; constraintChangeCount?: number; - latestConstraintTitle?: string; + latestConstraint?: IConstraintChange; /** Save status */ saveStatus: 'idle' | 'saving' | 'saved' | 'error'; /** Timer state */ @@ -35,7 +37,7 @@ const DIFFICULTY_LABELS: Record = { export function InterviewHeader({ difficulty, constraintChangeCount = 0, - latestConstraintTitle, + latestConstraint, saveStatus, timer, status, @@ -97,7 +99,7 @@ export function InterviewHeader({ {constraintChangeCount > 0 && (
bolt @@ -113,7 +115,11 @@ export function InterviewHeader({
- + {latestConstraint && latestConstraint.status !== 'addressed' ? ( + + ) : ( + + )}
diff --git a/src/lib/db/models/InterviewSession.ts b/src/lib/db/models/InterviewSession.ts index 0bbaddb..e81e084 100644 --- a/src/lib/db/models/InterviewSession.ts +++ b/src/lib/db/models/InterviewSession.ts @@ -37,6 +37,7 @@ export interface IConstraintChange { status: ConstraintChangeStatus; impactAreas: string[]; interviewerMessage: string; + impactedNodeId?: string; } // Full evaluation result @@ -141,6 +142,7 @@ const ConstraintChangeSchema = new Schema( }, impactAreas: { type: [String], default: [] }, interviewerMessage: { type: String, required: true }, + impactedNodeId: { type: String, default: null }, }, { _id: false } ); From 4e81223639fdf1d67d40b2761aacc70b4ec8ca17 Mon Sep 17 00:00:00 2001 From: Shashank Date: Sun, 29 Mar 2026 20:29:55 +0530 Subject: [PATCH 2/9] feat(chaos): add realistic AI timeout interactions and overtime UI --- app/api/interview/[id]/chaos-timeout/route.ts | 65 ++++++++++++ app/interview/[id]/page.tsx | 18 ++++ components/interview/ChaosTimer.tsx | 100 ++++++++++++------ components/interview/InterviewHeader.tsx | 11 +- src/hooks/useInterviewAI.ts | 3 +- src/lib/db/models/InterviewSession.ts | 4 + 6 files changed, 164 insertions(+), 37 deletions(-) create mode 100644 app/api/interview/[id]/chaos-timeout/route.ts diff --git a/app/api/interview/[id]/chaos-timeout/route.ts b/app/api/interview/[id]/chaos-timeout/route.ts new file mode 100644 index 0000000..fb6cf30 --- /dev/null +++ b/app/api/interview/[id]/chaos-timeout/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/src/lib/db/mongoose'; +import InterviewSession from '@/src/lib/db/models/InterviewSession'; + +export async function POST( + req: NextRequest, + context: { params: { id: string } } +) { + try { + await dbConnect(); + const id = context.params.id; + const { type, constraintId } = await req.json(); + + // 1. Fetch Session + const session = await InterviewSession.findOne({ id }); + if (!session) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + + // 2. Locate Constraint + const constraintChanges = session.constraintChanges || []; + const constraintIndex = constraintChanges.findIndex((c: any) => c.id === constraintId); + if (constraintIndex === -1) { + return NextResponse.json({ error: 'Constraint not found' }, { status: 404 }); + } + + const constraint = constraintChanges[constraintIndex]; + + // 3. Early Returns (already addressed or failed) + if (constraint.status === 'addressed' || (type === 'warning' && constraint.overtimeAt) || (type === 'penalty' && constraint.failedAt)) { + return NextResponse.json({ success: true, warning: 'Already processed' }); + } + + if (!session.aiMessages) session.aiMessages = []; + + // 4. Update state depending on timeout type + if (type === 'warning') { + constraint.overtimeAt = new Date(); + session.aiMessages.push({ + role: 'interviewer', + content: `Hey, I noticed we still haven't addressed the ${constraint.title} issue. In a real-world scenario, leaving a failure like this unhandled could lead to a broader system outage. Could you walk me through how you'd mitigate this in the next 2 minutes? Let's treat this as a high-priority incident.`, + timestamp: new Date() + }); + session.markModified('constraintChanges'); + session.markModified('aiMessages'); + } else if (type === 'penalty') { + constraint.failedAt = new Date(); + // We keep status as 'active' so the UI node stays visibly broken + session.aiMessages.push({ + role: 'interviewer', + content: `Alright, time's up on the ${constraint.title} scenario. Since we weren't able to establish a complete mitigation plan in time, I'll be noting this gap in high-availability planning for the evaluation. That said, let's keep moving forward with the rest of your system design—what were you thinking for the next component?`, + timestamp: new Date() + }); + session.markModified('constraintChanges'); + session.markModified('aiMessages'); + } + + await session.save(); + + return NextResponse.json({ success: true, messages: session.aiMessages, constraintChanges: session.constraintChanges }); + } catch (error) { + console.error('Chaos Timeout Error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 47fff56..f944601 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -323,6 +323,23 @@ export default function InterviewCanvasPage({ params }: PageProps) { if (!isAuthenticated) return null; + const handleChaosTimeout = useCallback(async (type: 'warning' | 'penalty', constraintId: string) => { + try { + const res = await authFetch(`/api/interview/${id}/chaos-timeout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, constraintId }) + }); + const data = await res.json(); + if (data.success && data.messages) { + if (ai.setMessages) ai.setMessages(data.messages); + setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null); + } + } catch (err) { + console.error('Chaos timeout failed:', err); + } + }, [id, ai]); + const isReadOnly = session.status !== 'in_progress'; return ( @@ -337,6 +354,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { onSubmit={handleSubmit} isSubmitting={isSubmitting} sessionId={id} + onChaosTimeout={handleChaosTimeout} /> {/* Submit Error Banner */} diff --git a/components/interview/ChaosTimer.tsx b/components/interview/ChaosTimer.tsx index 4a7e55b..8916f6d 100644 --- a/components/interview/ChaosTimer.tsx +++ b/components/interview/ChaosTimer.tsx @@ -1,58 +1,96 @@ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; +import { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; interface ChaosTimerProps { - introducedAt: Date | string; - timeLimitMs?: number; - onExpired?: () => void; + constraint: IConstraintChange; + onTimeout: (type: 'warning' | 'penalty', constraintId: string) => void; } -export function ChaosTimer({ introducedAt, timeLimitMs = 300000, onExpired }: ChaosTimerProps) { - const onExpiredRef = useRef(onExpired); +export function ChaosTimer({ constraint, onTimeout }: ChaosTimerProps) { + const onTimeoutRef = useRef(onTimeout); useEffect(() => { - onExpiredRef.current = onExpired; - }, [onExpired]); + onTimeoutRef.current = onTimeout; + }, [onTimeout]); - const calculateRemaining = useCallback((): number => { - const start = new Date(introducedAt).getTime(); - const endTime = start + timeLimitMs; - const remaining = Math.max(0, endTime - Date.now()); - return Math.ceil(remaining / 1000); - }, [introducedAt, timeLimitMs]); + // Fast-track values for testing (normally 5 mins and 2 mins) + // Actually, stick to 5m and 2m per user requirement + const WARNING_MS = 5 * 60 * 1000; + const PENALTY_MS = 2 * 60 * 1000; - const [secondsRemaining, setSecondsRemaining] = useState(() => calculateRemaining()); + const calculateRemaining = useCallback(() => { + if (constraint.failedAt) return { remaining: 0, mode: 'failed' as const }; + + const now = Date.now(); + if (constraint.overtimeAt) { + const overtimeEnd = new Date(constraint.overtimeAt).getTime() + PENALTY_MS; + return { remaining: Math.ceil(Math.max(0, overtimeEnd - now) / 1000), mode: 'overtime' as const }; + } - useEffect(() => { - const remainingInit = calculateRemaining(); - setSecondsRemaining(remainingInit); + const normalEnd = new Date(constraint.introducedAt).getTime() + WARNING_MS; + const remaining = normalEnd - now; - if (remainingInit <= 0) return; + if (remaining <= 0) { + // It hit 0 naturally but we haven't received DB overtime flag yet + return { remaining: 0, mode: 'normal' as const }; + } + return { remaining: Math.ceil(remaining / 1000), mode: 'normal' as const }; + }, [constraint]); + + const [state, setState] = useState(() => calculateRemaining()); + + // To prevent spamming the effect while waiting for DB response + const hasTriggeredWarning = useRef(false); + const hasTriggeredPenalty = useRef(false); + + useEffect(() => { + // Reset triggers if DB fields update + if (constraint.overtimeAt) hasTriggeredWarning.current = false; + if (constraint.failedAt) hasTriggeredPenalty.current = false; const interval = setInterval(() => { const current = calculateRemaining(); - setSecondsRemaining(current); - if (current <= 0) { - clearInterval(interval); - onExpiredRef.current?.(); + setState(current); + + if (current.remaining <= 0) { + if (current.mode === 'normal' && !constraint.overtimeAt && !hasTriggeredWarning.current) { + hasTriggeredWarning.current = true; + onTimeoutRef.current('warning', constraint.id); + } else if (current.mode === 'overtime' && !constraint.failedAt && !hasTriggeredPenalty.current) { + hasTriggeredPenalty.current = true; + onTimeoutRef.current('penalty', constraint.id); + } } }, 1000); return () => clearInterval(interval); - }, [calculateRemaining]); + }, [calculateRemaining, constraint]); + + if (state.mode === 'failed') { + return ( +
+ error + Unresolved Incident +
+ ); + } - const minutes = Math.floor(secondsRemaining / 60); - const seconds = secondsRemaining % 60; + const minutes = Math.floor(state.remaining / 60); + const seconds = state.remaining % 60; const formatted = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - const isExpired = secondsRemaining <= 0; + + const isOvertime = state.mode === 'overtime'; return ( -
- warning +
+ warning
- Evacuate / Mitigate - - {isExpired ? '00:00' : formatted} + + {isOvertime ? 'Final 2 Minutes' : 'Address Failure'} + + + {formatted}
diff --git a/components/interview/InterviewHeader.tsx b/components/interview/InterviewHeader.tsx index 6457357..cc4871c 100644 --- a/components/interview/InterviewHeader.tsx +++ b/components/interview/InterviewHeader.tsx @@ -26,6 +26,7 @@ interface InterviewHeaderProps { isSubmitting?: boolean; /** Session ID for linking to results */ sessionId?: string; + onChaosTimeout?: (type: 'warning' | 'penalty', id: string) => void; } const DIFFICULTY_LABELS: Record = { @@ -44,6 +45,7 @@ export function InterviewHeader({ onSubmit, isSubmitting = false, sessionId, + onChaosTimeout, }: InterviewHeaderProps) { const diffConfig = DIFFICULTY_LABELS[difficulty] || DIFFICULTY_LABELS.medium; const isInProgress = status === 'in_progress'; @@ -114,12 +116,11 @@ export function InterviewHeader({ {renderSaveStatus()}
-
- {latestConstraint && latestConstraint.status !== 'addressed' ? ( - - ) : ( - +
+ {latestConstraint && latestConstraint.status !== 'addressed' && ( + {})} /> )} +
diff --git a/src/hooks/useInterviewAI.ts b/src/hooks/useInterviewAI.ts index 621bd4c..d6fa854 100644 --- a/src/hooks/useInterviewAI.ts +++ b/src/hooks/useInterviewAI.ts @@ -149,6 +149,7 @@ export function useInterviewAI({ return { messages, isThinking, - sendReply + sendReply, + setMessages }; } diff --git a/src/lib/db/models/InterviewSession.ts b/src/lib/db/models/InterviewSession.ts index e81e084..cec552a 100644 --- a/src/lib/db/models/InterviewSession.ts +++ b/src/lib/db/models/InterviewSession.ts @@ -38,6 +38,8 @@ export interface IConstraintChange { impactAreas: string[]; interviewerMessage: string; impactedNodeId?: string; + overtimeAt?: Date; + failedAt?: Date; } // Full evaluation result @@ -143,6 +145,8 @@ const ConstraintChangeSchema = new Schema( impactAreas: { type: [String], default: [] }, interviewerMessage: { type: String, required: true }, impactedNodeId: { type: String, default: null }, + overtimeAt: { type: Date, default: null }, + failedAt: { type: Date, default: null }, }, { _id: false } ); From 8c518628a32ea2e8e223f585353f3f18dd13aa09 Mon Sep 17 00:00:00 2001 From: Shashank Date: Sun, 29 Mar 2026 20:43:46 +0530 Subject: [PATCH 3/9] fix(chaos): await route params to resolve Next.js 15 build failure --- app/api/interview/[id]/chaos-timeout/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/interview/[id]/chaos-timeout/route.ts b/app/api/interview/[id]/chaos-timeout/route.ts index fb6cf30..62a021c 100644 --- a/app/api/interview/[id]/chaos-timeout/route.ts +++ b/app/api/interview/[id]/chaos-timeout/route.ts @@ -4,11 +4,11 @@ import InterviewSession from '@/src/lib/db/models/InterviewSession'; export async function POST( req: NextRequest, - context: { params: { id: string } } + context: { params: Promise<{ id: string }> } ) { try { await dbConnect(); - const id = context.params.id; + const { id } = await context.params; const { type, constraintId } = await req.json(); // 1. Fetch Session From 4ae144a62c75630e09fc1249b785fa1d5a2573a1 Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 30 Mar 2026 04:16:00 +0530 Subject: [PATCH 4/9] eat(simulation): add dynamic load engine and final validation phase - Implemented graph traversal for realtime load simulation. - Added UI widget to canvas with variable RPS slider. - Integrated deterministic graph bottlenecks with SVG marching ants and warning badges. - Created Backend API and frontend trigger for 10-minute 'Final Validation Phase'. - Fixed React hooks ordering rules violation with . - Automatically open AI Interviewer sidebar during chaos events and validation. --- .../interview/[id]/final-validation/route.ts | 42 ++++++ app/interview/[id]/page.tsx | 53 +++++--- components/canvas/DesignCanvas.tsx | 79 ++++++++++-- components/canvas/SimulationControls.tsx | 56 ++++++++ src/hooks/useSimulationEngine.ts | 33 +++++ src/lib/simulation/constants.ts | 23 ++++ src/lib/simulation/engine.ts | 122 ++++++++++++++++++ 7 files changed, 381 insertions(+), 27 deletions(-) create mode 100644 app/api/interview/[id]/final-validation/route.ts create mode 100644 components/canvas/SimulationControls.tsx create mode 100644 src/hooks/useSimulationEngine.ts create mode 100644 src/lib/simulation/constants.ts create mode 100644 src/lib/simulation/engine.ts diff --git a/app/api/interview/[id]/final-validation/route.ts b/app/api/interview/[id]/final-validation/route.ts new file mode 100644 index 0000000..534a84e --- /dev/null +++ b/app/api/interview/[id]/final-validation/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/src/lib/db/mongoose'; +import InterviewSession from '@/src/lib/db/models/InterviewSession'; + +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + await dbConnect(); + const { id } = await context.params; + + const session = await InterviewSession.findOne({ id }); + if (!session) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + + if (!session.aiMessages) session.aiMessages = []; + + // Idempotency check: Have we already sent it? + const alreadySent = session.aiMessages.some((m: any) => + m.role === 'interviewer' && m.content.includes("Final Validation Phase") + ); + + if (alreadySent || session.status !== 'in_progress') { + return NextResponse.json({ success: true, messages: session.aiMessages }); + } + + session.aiMessages.push({ + role: 'interviewer', + content: `**Final Validation Phase**: We have roughly 10 minutes left in the interview! It's time to test your architecture's resiliency. Please turn to the **Simulation Controls** panel, hit 'Run Test', and use the **Target Throughput** slider to simulate traffic. Talk me through how your system behaves under different load scenarios, and point out any bottlenecks.`, + timestamp: new Date() + }); + session.markModified('aiMessages'); + await session.save(); + + return NextResponse.json({ success: true, messages: session.aiMessages }); + } catch (error) { + console.error('Final Validation Error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index f944601..890b23f 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -56,6 +56,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { const [showHints, setShowHints] = useState(false); const [submitError, setSubmitError] = useState(null); const [isInterviewPanelOpen, setIsInterviewPanelOpen] = useState(false); + const [finalValidationTriggered, setFinalValidationTriggered] = useState(false); // Refs for save logic const isSavingRef = useRef(false); @@ -100,6 +101,23 @@ export default function InterviewCanvasPage({ params }: PageProps) { onConstraintChange: handleConstraintChange }); + // Final Validation Phase Trigger automatically checks and dispatches. + useEffect(() => { + if (!finalValidationTriggered && timer.minutes !== undefined && timer.minutes <= 10 && session?.status === 'in_progress') { + setFinalValidationTriggered(true); + + authFetch(`/api/interview/${id}/final-validation`, { method: 'POST' }) + .then(res => res.json()) + .then(data => { + if (data.success && data.messages) { + if (ai.setMessages) ai.setMessages(data.messages); + setIsInterviewPanelOpen(true); + } + }) + .catch(err => console.error('Final validation trigger failed:', err)); + } + }, [timer.minutes, finalValidationTriggered, session?.status, id, ai.setMessages]); + // Fetch session data const fetchSession = useCallback(async () => { if (!user?.uid || !id) return; @@ -284,6 +302,24 @@ export default function InterviewCanvasPage({ params }: PageProps) { }; }, [id]); + const handleChaosTimeout = useCallback(async (type: 'warning' | 'penalty', constraintId: string) => { + try { + const res = await authFetch(`/api/interview/${id}/chaos-timeout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, constraintId }) + }); + const data = await res.json(); + if (data.success && data.messages) { + if (ai.setMessages) ai.setMessages(data.messages); + setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null); + setIsInterviewPanelOpen(true); // Automatically slide open the interviewer panel + } + } catch (err) { + console.error('Chaos timeout failed:', err); + } + }, [id, ai]); + // Loading state if (authLoading || isLoading) { return ( @@ -323,23 +359,6 @@ export default function InterviewCanvasPage({ params }: PageProps) { if (!isAuthenticated) return null; - const handleChaosTimeout = useCallback(async (type: 'warning' | 'penalty', constraintId: string) => { - try { - const res = await authFetch(`/api/interview/${id}/chaos-timeout`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type, constraintId }) - }); - const data = await res.json(); - if (data.success && data.messages) { - if (ai.setMessages) ai.setMessages(data.messages); - setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null); - } - } catch (err) { - console.error('Chaos timeout failed:', err); - } - }, [id, ai]); - const isReadOnly = session.status !== 'in_progress'; return ( diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index fcfb5c5..04e4ae9 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -2,6 +2,8 @@ import { useState, useRef, useId, useCallback, useEffect, useReducer, MutableRefObject } from 'react'; import { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; +import { useSimulationEngine } from '@/src/hooks/useSimulationEngine'; +import { SimulationControls } from './SimulationControls'; // Color mapping for different component types const COLOR_MAP: Record = { @@ -180,6 +182,11 @@ export function DesignCanvas({ } }, [nodes, connections, stateRef]); + // Simulation Engine State + const [isSimulationRunning, setIsSimulationRunning] = useState(false); + const [targetRps, setTargetRps] = useState(10000); + const simulationMetrics = useSimulationEngine(nodes, connections, targetRps, isSimulationRunning); + // Selection state const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedConnectionId, setSelectedConnectionId] = useState(null); @@ -725,6 +732,21 @@ export function DesignCanvas({ onMouseLeave={handleMouseUp} onClick={handleCanvasClick} > + + + {!readOnly && ( + + )} + {/* Grid Background (fixed) */}
@@ -775,16 +797,31 @@ export function DesignCanvas({ {connections.map((conn) => { const isSelected = conn.id === selectedConnectionId; const pathD = getConnectionPath(conn.from, conn.to); + const edgeMetric = simulationMetrics.edgeMetrics[conn.id]; + const isFlowing = isSimulationRunning && edgeMetric && edgeMetric.trafficFlow > 0; + return ( - + + + {isFlowing && ( + + )} + ); })} @@ -808,14 +845,22 @@ export function DesignCanvas({ const isSelected = node.id === selectedNodeId; const isImpacted = activeConstraints.some(c => c.impactedNodeId === node.id && c.status === 'active'); + + const nodeMetric = simulationMetrics.nodeMetrics[node.id]; + const isBottlenecked = isSimulationRunning && nodeMetric?.status === 'bottlenecked'; + const isWarning = isSimulationRunning && nodeMetric?.status === 'warning'; return (
+ {isBottlenecked && !isImpacted && ( +
+ BOTTLENECK +
+ )} + + {isSimulationRunning && nodeMetric && node.type !== 'Client' && ( +
+ {(nodeMetric.trafficIn / 1000).toFixed(1)}k + / + {(nodeMetric.capacity / 1000).toFixed(1)}k RPS +
+ )} + {/* Delete button - visible when selected and not readOnly */} {isSelected && !readOnly && ( +
+ +
+
+ Target Throughput + {targetRps.toLocaleString()} RPS +
+ onChangeRps(parseInt(e.target.value, 10))} + disabled={!isRunning} + className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-slate-200 dark:bg-slate-700 accent-primary focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+ 0 + 100k + 250k+ +
+
+
+ ); +} diff --git a/src/hooks/useSimulationEngine.ts b/src/hooks/useSimulationEngine.ts new file mode 100644 index 0000000..c3ae628 --- /dev/null +++ b/src/hooks/useSimulationEngine.ts @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react'; +import { ICanvasNode, IConnection } from '@/src/lib/db/models/Design'; +import { runSimulation, SimulationResult } from '@/src/lib/simulation/engine'; + +export function useSimulationEngine( + nodes: ICanvasNode[], + connections: IConnection[], + targetRps: number, + isRunning: boolean +) { + const [metrics, setMetrics] = useState({ + nodeMetrics: {}, + edgeMetrics: {}, + globalStatus: 'healthy' + }); + + useEffect(() => { + if (!isRunning || targetRps <= 0) { + setMetrics({ + nodeMetrics: {}, + edgeMetrics: {}, + globalStatus: 'healthy' + }); + return; + } + + const result = runSimulation(nodes, connections, targetRps); + setMetrics(result); + + }, [nodes, connections, targetRps, isRunning]); + + return metrics; +} diff --git a/src/lib/simulation/constants.ts b/src/lib/simulation/constants.ts new file mode 100644 index 0000000..300f3d9 --- /dev/null +++ b/src/lib/simulation/constants.ts @@ -0,0 +1,23 @@ +export const NODE_CAPACITIES: Record = { + Client: Infinity, // Clients generate load, no limits + Server: 5000, // Standard app server handles 5k RPS + Function: 2000, // Serverless function handles 2k concurrent + LB: 100000, // Load Balancer handles 100k RPS + CDN: 500000, // CDN handles 500k RPS (edge cached) + SQL: 3000, // Relational DB handles 3k writes/reads + Cache: 50000, // Redis Cache handles 50k RPS + Blob: 10000, // S3 handles 10k RPS + Queue: 20000, // Message queue handles 20k RPS + Kafka: 100000, // Distributed log handles 100k RPS +}; + +export interface NodeMetrics { + trafficIn: number; + trafficOut: number; + capacity: number; + status: 'normal' | 'bottlenecked' | 'warning'; +} + +export interface EdgeMetrics { + trafficFlow: number; +} diff --git a/src/lib/simulation/engine.ts b/src/lib/simulation/engine.ts new file mode 100644 index 0000000..1604499 --- /dev/null +++ b/src/lib/simulation/engine.ts @@ -0,0 +1,122 @@ +import { ICanvasNode, IConnection } from '../db/models/Design'; +import { NODE_CAPACITIES, NodeMetrics, EdgeMetrics } from './constants'; + +export interface SimulationResult { + nodeMetrics: Record; + edgeMetrics: Record; + globalStatus: 'healthy' | 'degraded' | 'critical'; +} + +export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], targetRps: number): SimulationResult { + const nodeMetrics: Record = {}; + const edgeMetrics: Record = {}; + + // Initialize metrics + nodes.forEach(node => { + nodeMetrics[node.id] = { + trafficIn: 0, + trafficOut: 0, + capacity: NODE_CAPACITIES[node.type] || 5000, + status: 'normal' + }; + }); + + edges.forEach(edge => { + edgeMetrics[edge.id] = { trafficFlow: 0 }; + }); + + if (targetRps <= 0) { + return { nodeMetrics, edgeMetrics, globalStatus: 'healthy' }; + } + + // Identify sources (Clients or nodes with 0 in-degree if no clients) + let sources = nodes.filter(n => n.type === 'Client'); + if (sources.length === 0) { + const hasIncoming = new Set(edges.map(e => e.to)); + sources = nodes.filter(n => !hasIncoming.has(n.id)); + } + + if (sources.length === 0) { + // Total cycle, pick a random node to start + if (nodes.length > 0) sources = [nodes[0]]; + } + + // Build Adjacency List and InDegrees for Kahn's + const adj: Record = {}; + const inDegree: Record = {}; + + nodes.forEach(n => { + adj[n.id] = []; + inDegree[n.id] = 0; + }); + + edges.forEach(e => { + if (adj[e.from]) adj[e.from].push(e); + if (inDegree[e.to] !== undefined) inDegree[e.to]++; + }); + + // We will do a modified iterative propagation to handle cycles softly. + // Instead of strict topological sort, we do a fixed number of iterations (e.g. 5 passes) + // For pure DAGs, it propagates cleanly. For cycles, it stabilizes. + + // Initial Load + sources.forEach(s => { + if (nodeMetrics[s.id]) { + nodeMetrics[s.id].trafficIn += targetRps / sources.length; + } + }); + + // 10 passes is enough for most UI architectures + for (let pass = 0; pass < 10; pass++) { + // Reset edge flows before recalculating this pass's spread + edges.forEach(e => { edgeMetrics[e.id].trafficFlow = 0; }); + + // We need a snapshot of trafficIn for the current frame to distribute it properly + const currentTrafficIn = Object.keys(nodeMetrics).reduce((acc, id) => { + acc[id] = nodeMetrics[id].trafficIn; + // Clear trafficIn for non-sources so they can receive the fresh wave + if (!sources.find(s => s.id === id)) { + nodeMetrics[id].trafficIn = 0; + } + return acc; + }, {} as Record); + + nodes.forEach(node => { + const metrics = nodeMetrics[node.id]; + const processingTraffic = currentTrafficIn[node.id]; + + // Cap out at capacity + const outFlow = Math.min(processingTraffic, metrics.capacity); + metrics.trafficOut = outFlow; + + // Update status + if (processingTraffic > metrics.capacity) { + metrics.status = 'bottlenecked'; + } else if (processingTraffic > metrics.capacity * 0.8) { + metrics.status = 'warning'; + } else { + metrics.status = 'normal'; + } + + // Distribute to children + const outgoingEdges = adj[node.id]; + if (outgoingEdges && outgoingEdges.length > 0) { + const flowPerEdge = outFlow / outgoingEdges.length; + outgoingEdges.forEach(edge => { + edgeMetrics[edge.id].trafficFlow += flowPerEdge; + if (nodeMetrics[edge.to]) { + nodeMetrics[edge.to].trafficIn += flowPerEdge; + } + }); + } + }); + } + + // Evaluate Global Status + const bottleneckCount = Object.values(nodeMetrics).filter(m => m.status === 'bottlenecked').length; + let globalStatus: 'healthy' | 'degraded' | 'critical' = 'healthy'; + if (bottleneckCount > 2) globalStatus = 'critical'; + else if (bottleneckCount > 0) globalStatus = 'degraded'; + + return { nodeMetrics, edgeMetrics, globalStatus }; +} From 85555bb21c1c24466304d18386c482e6846806f0 Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 30 Mar 2026 04:45:52 +0530 Subject: [PATCH 5/9] fix: Lint issues --- app/api/interview/[id]/chaos-timeout/route.ts | 2 +- app/api/interview/[id]/final-validation/route.ts | 2 +- app/interview/[id]/page.tsx | 2 +- components/canvas/DesignCanvas.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/api/interview/[id]/chaos-timeout/route.ts b/app/api/interview/[id]/chaos-timeout/route.ts index 62a021c..0363f12 100644 --- a/app/api/interview/[id]/chaos-timeout/route.ts +++ b/app/api/interview/[id]/chaos-timeout/route.ts @@ -19,7 +19,7 @@ export async function POST( // 2. Locate Constraint const constraintChanges = session.constraintChanges || []; - const constraintIndex = constraintChanges.findIndex((c: any) => c.id === constraintId); + const constraintIndex = constraintChanges.findIndex((c: { id: string }) => c.id === constraintId); if (constraintIndex === -1) { return NextResponse.json({ error: 'Constraint not found' }, { status: 404 }); } diff --git a/app/api/interview/[id]/final-validation/route.ts b/app/api/interview/[id]/final-validation/route.ts index 534a84e..2561940 100644 --- a/app/api/interview/[id]/final-validation/route.ts +++ b/app/api/interview/[id]/final-validation/route.ts @@ -18,7 +18,7 @@ export async function POST( if (!session.aiMessages) session.aiMessages = []; // Idempotency check: Have we already sent it? - const alreadySent = session.aiMessages.some((m: any) => + const alreadySent = session.aiMessages.some((m: { role: string; content: string }) => m.role === 'interviewer' && m.content.includes("Final Validation Phase") ); diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 890b23f..29559b2 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -116,7 +116,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { }) .catch(err => console.error('Final validation trigger failed:', err)); } - }, [timer.minutes, finalValidationTriggered, session?.status, id, ai.setMessages]); + }, [timer.minutes, finalValidationTriggered, session?.status, id, ai]); // Fetch session data const fetchSession = useCallback(async () => { diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 04e4ae9..4606b7c 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -318,14 +318,14 @@ export function DesignCanvas({ setSelectedNodeId(null); setSelectedConnectionId(null); setTempNodes(null); - }, []); + }, [dispatch, setSelectedNodeId, setSelectedConnectionId, setTempNodes]); const handleRedo = useCallback(() => { dispatch({ type: 'REDO' }); setSelectedNodeId(null); setSelectedConnectionId(null); setTempNodes(null); - }, []); + }, [dispatch, setSelectedNodeId, setSelectedConnectionId, setTempNodes]); // Zoom controls const handleZoomIn = useCallback(() => { From dd9f9cb1b393756b685fff5b627ea08416cb8503 Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 30 Mar 2026 05:04:34 +0530 Subject: [PATCH 6/9] fix: Lint and build issues --- app/api/interview/[id]/chaos-timeout/route.ts | 7 +++-- .../interview/[id]/final-validation/route.ts | 19 ++++++++++-- app/api/interview/[id]/hint/route.ts | 4 +-- app/interview/[id]/page.tsx | 18 +++++++---- components/canvas/DesignCanvas.tsx | 2 +- components/canvas/SimulationControls.tsx | 1 - components/interview/ChaosTimer.tsx | 30 +++++++++---------- src/hooks/useInterviewAI.ts | 5 ++++ src/lib/simulation/engine.ts | 3 +- 9 files changed, 57 insertions(+), 32 deletions(-) diff --git a/app/api/interview/[id]/chaos-timeout/route.ts b/app/api/interview/[id]/chaos-timeout/route.ts index 0363f12..c96e98d 100644 --- a/app/api/interview/[id]/chaos-timeout/route.ts +++ b/app/api/interview/[id]/chaos-timeout/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import dbConnect from '@/src/lib/db/mongoose'; -import InterviewSession from '@/src/lib/db/models/InterviewSession'; +import InterviewSession, { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; export async function POST( req: NextRequest, @@ -19,7 +19,7 @@ export async function POST( // 2. Locate Constraint const constraintChanges = session.constraintChanges || []; - const constraintIndex = constraintChanges.findIndex((c: { id: string }) => c.id === constraintId); + const constraintIndex = constraintChanges.findIndex((c: IConstraintChange) => c.id === constraintId); if (constraintIndex === -1) { return NextResponse.json({ error: 'Constraint not found' }, { status: 404 }); } @@ -45,7 +45,8 @@ export async function POST( session.markModified('aiMessages'); } else if (type === 'penalty') { constraint.failedAt = new Date(); - // We keep status as 'active' so the UI node stays visibly broken + constraint.status = 'addressed'; + // We keep status as 'addressed' but the failedAt timestamp ensures it's tracked as a failure session.aiMessages.push({ role: 'interviewer', content: `Alright, time's up on the ${constraint.title} scenario. Since we weren't able to establish a complete mitigation plan in time, I'll be noting this gap in high-availability planning for the evaluation. That said, let's keep moving forward with the rest of your system design—what were you thinking for the next component?`, diff --git a/app/api/interview/[id]/final-validation/route.ts b/app/api/interview/[id]/final-validation/route.ts index 2561940..3206994 100644 --- a/app/api/interview/[id]/final-validation/route.ts +++ b/app/api/interview/[id]/final-validation/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import dbConnect from '@/src/lib/db/mongoose'; -import InterviewSession from '@/src/lib/db/models/InterviewSession'; +import InterviewSession, { IInterviewSession } from '@/src/lib/db/models/InterviewSession'; +import User from '@/src/lib/db/models/User'; +import { getAuthenticatedUser } from '@/src/lib/firebase/firebaseAdmin'; export async function POST( req: NextRequest, @@ -10,7 +12,18 @@ export async function POST( await dbConnect(); const { id } = await context.params; - const session = await InterviewSession.findOne({ id }); + const authHeader = req.headers.get('Authorization'); + const authenticatedUser = await getAuthenticatedUser(authHeader); + if (!authenticatedUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await User.findOne({ firebaseUid: authenticatedUser.uid }); + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const session = await InterviewSession.findOne({ _id: id, userId: user._id }); if (!session) { return NextResponse.json({ error: 'Session not found' }, { status: 404 }); } @@ -18,7 +31,7 @@ export async function POST( if (!session.aiMessages) session.aiMessages = []; // Idempotency check: Have we already sent it? - const alreadySent = session.aiMessages.some((m: { role: string; content: string }) => + const alreadySent = session.aiMessages.some((m: NonNullable[number]) => m.role === 'interviewer' && m.content.includes("Final Validation Phase") ); diff --git a/app/api/interview/[id]/hint/route.ts b/app/api/interview/[id]/hint/route.ts index 4ec7be0..cd17eb8 100644 --- a/app/api/interview/[id]/hint/route.ts +++ b/app/api/interview/[id]/hint/route.ts @@ -68,8 +68,8 @@ function generateConstraintChange( impactAreas: ['scalability', 'caching', 'load balancing'], }; } else if ((databaseCount <= 1 || mentionsDisconnected) && difficulty !== 'easy') { - const candidateNodes = nodes.filter((n) => ['SQL', 'Blob', 'Cache', 'Server'].includes(n.type)); - const victim = candidateNodes[Math.floor(Math.random() * candidateNodes.length)]; + const candidateNodes = nodes.filter((n) => ['SQL', 'Blob', 'Cache', 'Server', 'LB', 'Queue', 'Kafka', 'Function', 'CDN'].includes(n.type)); + const victim = candidateNodes.length > 0 ? candidateNodes[Math.floor(Math.random() * candidateNodes.length)] : undefined; selected = { type: 'reliability', title: 'Node Failure', diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 29559b2..5d3b4ee 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -101,22 +101,30 @@ export default function InterviewCanvasPage({ params }: PageProps) { onConstraintChange: handleConstraintChange }); + const { setMessages } = ai; + // Final Validation Phase Trigger automatically checks and dispatches. useEffect(() => { if (!finalValidationTriggered && timer.minutes !== undefined && timer.minutes <= 10 && session?.status === 'in_progress') { setFinalValidationTriggered(true); authFetch(`/api/interview/${id}/final-validation`, { method: 'POST' }) - .then(res => res.json()) + .then(async res => { + if (!res.ok) { + const errText = await res.text(); + throw new Error(errText || res.statusText); + } + return res.json(); + }) .then(data => { if (data.success && data.messages) { - if (ai.setMessages) ai.setMessages(data.messages); + if (setMessages) setMessages(data.messages); setIsInterviewPanelOpen(true); } }) .catch(err => console.error('Final validation trigger failed:', err)); } - }, [timer.minutes, finalValidationTriggered, session?.status, id, ai]); + }, [timer.minutes, finalValidationTriggered, session?.status, id, setMessages]); // Fetch session data const fetchSession = useCallback(async () => { @@ -311,14 +319,14 @@ export default function InterviewCanvasPage({ params }: PageProps) { }); const data = await res.json(); if (data.success && data.messages) { - if (ai.setMessages) ai.setMessages(data.messages); + if (setMessages) setMessages(data.messages); setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null); setIsInterviewPanelOpen(true); // Automatically slide open the interviewer panel } } catch (err) { console.error('Chaos timeout failed:', err); } - }, [id, ai]); + }, [id, setMessages]); // Loading state if (authLoading || isLoading) { diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 4606b7c..fbd6f27 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -425,7 +425,7 @@ export function DesignCanvas({ } catch (err) { console.error('Failed to parse dropped component:', err); } - }, [nodes, connections, zoom, panOffset, saveToHistory, readOnly]); + }, [nodes, connections, zoom, panOffset, saveToHistory, readOnly, setSelectedNodeId, setSelectedConnectionId]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); diff --git a/components/canvas/SimulationControls.tsx b/components/canvas/SimulationControls.tsx index 2b730a9..ab63f15 100644 --- a/components/canvas/SimulationControls.tsx +++ b/components/canvas/SimulationControls.tsx @@ -42,7 +42,6 @@ export function SimulationControls({ step="5000" value={targetRps} onChange={(e) => onChangeRps(parseInt(e.target.value, 10))} - disabled={!isRunning} className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-slate-200 dark:bg-slate-700 accent-primary focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed" />
diff --git a/components/interview/ChaosTimer.tsx b/components/interview/ChaosTimer.tsx index 8916f6d..fbe2ea7 100644 --- a/components/interview/ChaosTimer.tsx +++ b/components/interview/ChaosTimer.tsx @@ -8,17 +8,17 @@ interface ChaosTimerProps { onTimeout: (type: 'warning' | 'penalty', constraintId: string) => void; } +// Fast-track values for testing (normally 5 mins and 2 mins) +// Actually, stick to 5m and 2m per user requirement +const WARNING_MS = 5 * 60 * 1000; +const PENALTY_MS = 2 * 60 * 1000; + export function ChaosTimer({ constraint, onTimeout }: ChaosTimerProps) { const onTimeoutRef = useRef(onTimeout); useEffect(() => { onTimeoutRef.current = onTimeout; }, [onTimeout]); - // Fast-track values for testing (normally 5 mins and 2 mins) - // Actually, stick to 5m and 2m per user requirement - const WARNING_MS = 5 * 60 * 1000; - const PENALTY_MS = 2 * 60 * 1000; - const calculateRemaining = useCallback(() => { if (constraint.failedAt) return { remaining: 0, mode: 'failed' as const }; @@ -40,25 +40,23 @@ export function ChaosTimer({ constraint, onTimeout }: ChaosTimerProps) { const [state, setState] = useState(() => calculateRemaining()); - // To prevent spamming the effect while waiting for DB response - const hasTriggeredWarning = useRef(false); - const hasTriggeredPenalty = useRef(false); + // Track if we have already dispatched the timeout API requests + const hasPendingWarningRequest = useRef(false); + const hasPendingPenaltyRequest = useRef(false); useEffect(() => { - // Reset triggers if DB fields update - if (constraint.overtimeAt) hasTriggeredWarning.current = false; - if (constraint.failedAt) hasTriggeredPenalty.current = false; - + // Reset timers on DB fields updates have been removed (unnecessary toggles) + const interval = setInterval(() => { const current = calculateRemaining(); setState(current); if (current.remaining <= 0) { - if (current.mode === 'normal' && !constraint.overtimeAt && !hasTriggeredWarning.current) { - hasTriggeredWarning.current = true; + if (current.mode === 'normal' && !constraint.overtimeAt && !hasPendingWarningRequest.current) { + hasPendingWarningRequest.current = true; onTimeoutRef.current('warning', constraint.id); - } else if (current.mode === 'overtime' && !constraint.failedAt && !hasTriggeredPenalty.current) { - hasTriggeredPenalty.current = true; + } else if (current.mode === 'overtime' && !constraint.failedAt && !hasPendingPenaltyRequest.current) { + hasPendingPenaltyRequest.current = true; onTimeoutRef.current('penalty', constraint.id); } } diff --git a/src/hooks/useInterviewAI.ts b/src/hooks/useInterviewAI.ts index d6fa854..bc0dfba 100644 --- a/src/hooks/useInterviewAI.ts +++ b/src/hooks/useInterviewAI.ts @@ -150,6 +150,11 @@ export function useInterviewAI({ messages, isThinking, sendReply, + /** + * Exposed strictly for synchronizing external AI messages generated + * via backend webhooks/events (e.g. final-validation, chaos-timeout). + * Do not use this to bypass `requestHint` or `sendReply`. + */ setMessages }; } diff --git a/src/lib/simulation/engine.ts b/src/lib/simulation/engine.ts index 1604499..9461b4b 100644 --- a/src/lib/simulation/engine.ts +++ b/src/lib/simulation/engine.ts @@ -72,10 +72,11 @@ export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], target edges.forEach(e => { edgeMetrics[e.id].trafficFlow = 0; }); // We need a snapshot of trafficIn for the current frame to distribute it properly + const sourceIds = new Set(sources.map(s => s.id)); const currentTrafficIn = Object.keys(nodeMetrics).reduce((acc, id) => { acc[id] = nodeMetrics[id].trafficIn; // Clear trafficIn for non-sources so they can receive the fresh wave - if (!sources.find(s => s.id === id)) { + if (!sourceIds.has(id)) { nodeMetrics[id].trafficIn = 0; } return acc; From b7cd350714e750adc88910dd388c3f6a0eac8a73 Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 30 Mar 2026 05:18:08 +0530 Subject: [PATCH 7/9] fix: Lint and build issues --- app/api/interview/[id]/chaos-timeout/route.ts | 5 +- app/interview/[id]/page.tsx | 47 +++++++++++-------- components/canvas/DesignCanvas.tsx | 16 +++---- src/hooks/useSimulationEngine.ts | 30 ++++-------- 4 files changed, 50 insertions(+), 48 deletions(-) diff --git a/app/api/interview/[id]/chaos-timeout/route.ts b/app/api/interview/[id]/chaos-timeout/route.ts index c96e98d..8d6e920 100644 --- a/app/api/interview/[id]/chaos-timeout/route.ts +++ b/app/api/interview/[id]/chaos-timeout/route.ts @@ -16,6 +16,9 @@ export async function POST( if (!session) { return NextResponse.json({ error: 'Session not found' }, { status: 404 }); } + if (session.status !== 'in_progress') { + return NextResponse.json({ success: true, warning: 'Session is no longer in progress' }); + } // 2. Locate Constraint const constraintChanges = session.constraintChanges || []; @@ -27,7 +30,7 @@ export async function POST( const constraint = constraintChanges[constraintIndex]; // 3. Early Returns (already addressed or failed) - if (constraint.status === 'addressed' || (type === 'warning' && constraint.overtimeAt) || (type === 'penalty' && constraint.failedAt)) { + if (constraint.status === 'addressed' || constraint.failedAt || (type === 'warning' && constraint.overtimeAt)) { return NextResponse.json({ success: true, warning: 'Already processed' }); } diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 5d3b4ee..987adf9 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -104,26 +104,35 @@ export default function InterviewCanvasPage({ params }: PageProps) { const { setMessages } = ai; // Final Validation Phase Trigger automatically checks and dispatches. + const finalValidationInFlight = useRef(false); useEffect(() => { - if (!finalValidationTriggered && timer.minutes !== undefined && timer.minutes <= 10 && session?.status === 'in_progress') { - setFinalValidationTriggered(true); - - authFetch(`/api/interview/${id}/final-validation`, { method: 'POST' }) - .then(async res => { - if (!res.ok) { - const errText = await res.text(); - throw new Error(errText || res.statusText); - } - return res.json(); - }) - .then(data => { - if (data.success && data.messages) { - if (setMessages) setMessages(data.messages); - setIsInterviewPanelOpen(true); - } - }) - .catch(err => console.error('Final validation trigger failed:', err)); - } + if (finalValidationTriggered || finalValidationInFlight.current) return; + if (timer.minutes === undefined || timer.minutes > 10) return; + if (session?.status !== 'in_progress') return; + + finalValidationInFlight.current = true; + + authFetch(`/api/interview/${id}/final-validation`, { method: 'POST' }) + .then(async res => { + if (!res.ok) { + const errText = await res.text(); + throw new Error(errText || res.statusText); + } + return res.json(); + }) + .then(data => { + setFinalValidationTriggered(true); + if (data.success && data.messages) { + if (setMessages) setMessages(data.messages); + setIsInterviewPanelOpen(true); + } + }) + .catch(err => { + console.error('Final validation trigger failed:', err); + }) + .finally(() => { + finalValidationInFlight.current = false; + }); }, [timer.minutes, finalValidationTriggered, session?.status, id, setMessages]); // Fetch session data diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index fbd6f27..d7178f1 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -487,7 +487,7 @@ export function DesignCanvas({ x: e.clientX / scale - node.x, y: e.clientY / scale - node.y, }); - }, [nodes, connections, toolMode, zoom, saveToHistory, readOnly]); + }, [nodes, connections, toolMode, zoom, saveToHistory, readOnly, setSelectedNodeId, setSelectedConnectionId]); // Handle completing a connection (mouse up on another node) const handleNodeMouseUp = useCallback((e: React.MouseEvent, nodeId: string) => { @@ -583,7 +583,7 @@ export function DesignCanvas({ setSelectedConnectionId(null); setEditingNodeId(null); // Cancel any open label editor setEditingConnectionId(null); // Cancel any open connection label editor - }, []); + }, [setSelectedNodeId, setSelectedConnectionId, setEditingConnectionId]); // Handle double-click on a node's label to start editing const handleLabelDoubleClick = useCallback((e: React.MouseEvent, nodeId: string) => { @@ -634,7 +634,7 @@ export function DesignCanvas({ setEditingConnectionId(connectionId); setEditingConnectionLabel(conn.label || ''); setTimeout(() => connectionLabelInputRef.current?.focus(), 0); - }, [readOnly, connections]); + }, [readOnly, connections, setEditingConnectionId, setEditingConnectionLabel]); // Commit the edited connection label const handleConnectionLabelSubmit = useCallback((connectionId: string) => { @@ -665,7 +665,7 @@ export function DesignCanvas({ skipNextDebouncedSaveRef.current = true; onSave(nodes, newConnections); } - }, [editingConnectionLabel, nodes, connections, saveToHistory, onSave]); + }, [editingConnectionLabel, nodes, connections, saveToHistory, onSave, setEditingConnectionId]); // Delete selected node or connection const handleDeleteSelected = useCallback(() => { @@ -680,7 +680,7 @@ export function DesignCanvas({ saveToHistory(nodes, newConnections); setSelectedConnectionId(null); } - }, [selectedNodeId, selectedConnectionId, nodes, connections, saveToHistory, readOnly]); + }, [selectedNodeId, selectedConnectionId, nodes, connections, saveToHistory, readOnly, setSelectedNodeId, setSelectedConnectionId]); // Keyboard shortcuts useEffect(() => { @@ -856,11 +856,11 @@ export function DesignCanvas({ data-node style={{ left: node.x, top: node.y }} className={`absolute w-[60px] h-[60px] rounded-xl flex flex-col items-center justify-center select-none shadow-lg transition-all duration-300 ${isImpacted - ? 'bg-red-500/10 border-2 border-red-500/50 opacity-80 grayscale-[50%] cursor-not-allowed' + ? 'bg-red-500/10 border-2 border-red-500/50 opacity-80 grayscale-[50%] cursor-not-allowed pointer-events-auto' : isBottlenecked - ? 'bg-red-600 border-2 border-red-500 shadow-[0_0_20px_rgba(220,38,38,0.7)] text-white ring-2 ring-red-500 animate-pulse' + ? 'bg-red-600 border-2 border-red-500 shadow-[0_0_20px_rgba(220,38,38,0.7)] text-white ring-2 ring-red-500 animate-pulse pointer-events-auto' : isWarning - ? 'bg-amber-500/20 border-2 border-amber-500 text-amber-500 shadow-[0_0_15px_rgba(245,158,11,0.5)]' + ? 'bg-amber-500/20 border-2 border-amber-500 text-amber-500 shadow-[0_0_15px_rgba(245,158,11,0.5)] pointer-events-auto' : 'bg-white dark:bg-[#1e1e24] cursor-move transition-shadow pointer-events-auto ' + (isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-white dark:ring-offset-[#0f1115] shadow-[0_0_20px_rgba(71,37,244,0.3)] z-20' : 'border-2 border-transparent hover:border-primary') diff --git a/src/hooks/useSimulationEngine.ts b/src/hooks/useSimulationEngine.ts index c3ae628..78bcae5 100644 --- a/src/hooks/useSimulationEngine.ts +++ b/src/hooks/useSimulationEngine.ts @@ -1,33 +1,23 @@ -import { useState, useEffect } from 'react'; +import { useMemo } from 'react'; import { ICanvasNode, IConnection } from '@/src/lib/db/models/Design'; import { runSimulation, SimulationResult } from '@/src/lib/simulation/engine'; +const EMPTY_METRICS: SimulationResult = { + nodeMetrics: {}, + edgeMetrics: {}, + globalStatus: 'healthy' +}; + export function useSimulationEngine( nodes: ICanvasNode[], connections: IConnection[], targetRps: number, isRunning: boolean ) { - const [metrics, setMetrics] = useState({ - nodeMetrics: {}, - edgeMetrics: {}, - globalStatus: 'healthy' - }); - - useEffect(() => { + return useMemo(() => { if (!isRunning || targetRps <= 0) { - setMetrics({ - nodeMetrics: {}, - edgeMetrics: {}, - globalStatus: 'healthy' - }); - return; + return EMPTY_METRICS; } - - const result = runSimulation(nodes, connections, targetRps); - setMetrics(result); - + return runSimulation(nodes, connections, targetRps); }, [nodes, connections, targetRps, isRunning]); - - return metrics; } From 4348409018d64bc4a0747847f1337fb963e9d1cb Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:23:46 +0000 Subject: [PATCH 8/9] fix: apply CodeRabbit auto-fixes Fixed 4 file(s) based on 5 unresolved review comments. Co-authored-by: CodeRabbit --- app/api/interview/[id]/chaos-timeout/route.ts | 43 +++++++++-- components/canvas/DesignCanvas.tsx | 9 ++- components/interview/ChaosTimer.tsx | 8 ++- src/lib/simulation/engine.ts | 71 +++++++++++++------ 4 files changed, 98 insertions(+), 33 deletions(-) diff --git a/app/api/interview/[id]/chaos-timeout/route.ts b/app/api/interview/[id]/chaos-timeout/route.ts index 8d6e920..f795abe 100644 --- a/app/api/interview/[id]/chaos-timeout/route.ts +++ b/app/api/interview/[id]/chaos-timeout/route.ts @@ -1,20 +1,51 @@ import { NextRequest, NextResponse } from 'next/server'; import dbConnect from '@/src/lib/db/mongoose'; import InterviewSession, { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; +import User from '@/src/lib/db/models/User'; +import { getAuthenticatedUser } from '@/src/lib/firebase/firebaseAdmin'; export async function POST( req: NextRequest, context: { params: Promise<{ id: string }> } ) { try { - await dbConnect(); const { id } = await context.params; - const { type, constraintId } = await req.json(); + const body = await req.json(); + + // Validate request body + const { type, constraintId } = body; + if (!type || (type !== 'warning' && type !== 'penalty')) { + return new Response(JSON.stringify({ error: 'Invalid type. Must be "warning" or "penalty".' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + if (!constraintId || (typeof constraintId !== 'string' && typeof constraintId !== 'number') || constraintId === '') { + return new Response(JSON.stringify({ error: 'Invalid constraintId. Must be a non-empty string or number.' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Authenticate user + const authHeader = req.headers.get('Authorization'); + const authenticatedUser = await getAuthenticatedUser(authHeader); + + if (!authenticatedUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await dbConnect(); + + const user = await User.findOne({ firebaseUid: authenticatedUser.uid }); + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } - // 1. Fetch Session - const session = await InterviewSession.findOne({ id }); + // 1. Fetch Session with ownership check + const session = await InterviewSession.findOne({ id, userId: user._id }); if (!session) { - return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + return NextResponse.json({ error: 'Session not found or access denied' }, { status: 403 }); } if (session.status !== 'in_progress') { return NextResponse.json({ success: true, warning: 'Session is no longer in progress' }); @@ -66,4 +97,4 @@ export async function POST( console.error('Chaos Timeout Error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index d7178f1..14dd672 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -187,6 +187,13 @@ export function DesignCanvas({ const [targetRps, setTargetRps] = useState(10000); const simulationMetrics = useSimulationEngine(nodes, connections, targetRps, isSimulationRunning); + // Stop simulation when canvas becomes read-only + useEffect(() => { + if (readOnly && isSimulationRunning) { + setIsSimulationRunning(false); + } + }, [readOnly, isSimulationRunning]); + // Selection state const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedConnectionId, setSelectedConnectionId] = useState(null); @@ -1190,4 +1197,4 @@ export function DesignCanvas({
); -} +} \ No newline at end of file diff --git a/components/interview/ChaosTimer.tsx b/components/interview/ChaosTimer.tsx index fbe2ea7..8f4e6bd 100644 --- a/components/interview/ChaosTimer.tsx +++ b/components/interview/ChaosTimer.tsx @@ -45,8 +45,10 @@ export function ChaosTimer({ constraint, onTimeout }: ChaosTimerProps) { const hasPendingPenaltyRequest = useRef(false); useEffect(() => { - // Reset timers on DB fields updates have been removed (unnecessary toggles) - + // Reset pending request flags for each new active constraint + hasPendingWarningRequest.current = false; + hasPendingPenaltyRequest.current = false; + const interval = setInterval(() => { const current = calculateRemaining(); setState(current); @@ -93,4 +95,4 @@ export function ChaosTimer({ constraint, onTimeout }: ChaosTimerProps) {
); -} +} \ No newline at end of file diff --git a/src/lib/simulation/engine.ts b/src/lib/simulation/engine.ts index 9461b4b..4f0743a 100644 --- a/src/lib/simulation/engine.ts +++ b/src/lib/simulation/engine.ts @@ -55,10 +55,9 @@ export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], target if (inDegree[e.to] !== undefined) inDegree[e.to]++; }); - // We will do a modified iterative propagation to handle cycles softly. - // Instead of strict topological sort, we do a fixed number of iterations (e.g. 5 passes) - // For pure DAGs, it propagates cleanly. For cycles, it stabilizes. - + // Iterative propagation with convergence detection to handle cycles properly + // We iterate until traffic distribution stabilizes or we hit maxIterations + // Initial Load sources.forEach(s => { if (nodeMetrics[s.id]) { @@ -66,30 +65,40 @@ export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], target } }); - // 10 passes is enough for most UI architectures - for (let pass = 0; pass < 10; pass++) { - // Reset edge flows before recalculating this pass's spread + const maxIterations = 100; + const epsilon = 0.01; // Convergence threshold: stop when total change < epsilon + const sourceIds = new Set(sources.map(s => s.id)); + + for (let iteration = 0; iteration < maxIterations; iteration++) { + // Store previous trafficIn values to measure convergence + const prevTrafficIn: Record = {}; + Object.keys(nodeMetrics).forEach(id => { + prevTrafficIn[id] = nodeMetrics[id].trafficIn; + }); + + // Reset edge flows before recalculating this iteration's spread edges.forEach(e => { edgeMetrics[e.id].trafficFlow = 0; }); - - // We need a snapshot of trafficIn for the current frame to distribute it properly - const sourceIds = new Set(sources.map(s => s.id)); - const currentTrafficIn = Object.keys(nodeMetrics).reduce((acc, id) => { - acc[id] = nodeMetrics[id].trafficIn; - // Clear trafficIn for non-sources so they can receive the fresh wave - if (!sourceIds.has(id)) { - nodeMetrics[id].trafficIn = 0; + + // Create next iteration's trafficIn map, starting with sources + const nextTrafficIn: Record = {}; + sourceIds.forEach(id => { + nextTrafficIn[id] = targetRps / sources.length; + }); + nodes.forEach(n => { + if (!sourceIds.has(n.id)) { + nextTrafficIn[n.id] = 0; } - return acc; - }, {} as Record); + }); + // Process each node: compute outFlow and distribute to children nodes.forEach(node => { const metrics = nodeMetrics[node.id]; - const processingTraffic = currentTrafficIn[node.id]; - + const processingTraffic = prevTrafficIn[node.id]; + // Cap out at capacity const outFlow = Math.min(processingTraffic, metrics.capacity); metrics.trafficOut = outFlow; - + // Update status if (processingTraffic > metrics.capacity) { metrics.status = 'bottlenecked'; @@ -105,12 +114,28 @@ export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], target const flowPerEdge = outFlow / outgoingEdges.length; outgoingEdges.forEach(edge => { edgeMetrics[edge.id].trafficFlow += flowPerEdge; - if (nodeMetrics[edge.to]) { - nodeMetrics[edge.to].trafficIn += flowPerEdge; + if (nextTrafficIn[edge.to] !== undefined) { + nextTrafficIn[edge.to] += flowPerEdge; } }); } }); + + // Update nodeMetrics with nextTrafficIn + Object.keys(nextTrafficIn).forEach(id => { + nodeMetrics[id].trafficIn = nextTrafficIn[id]; + }); + + // Measure total delta to check for convergence + let totalDelta = 0; + Object.keys(nodeMetrics).forEach(id => { + totalDelta += Math.abs(nodeMetrics[id].trafficIn - prevTrafficIn[id]); + }); + + // Stop if converged + if (totalDelta < epsilon) { + break; + } } // Evaluate Global Status @@ -120,4 +145,4 @@ export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], target else if (bottleneckCount > 0) globalStatus = 'degraded'; return { nodeMetrics, edgeMetrics, globalStatus }; -} +} \ No newline at end of file From d14f3e18933634091d3e9e2bfbf74a1b13bd880f Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 30 Mar 2026 05:58:49 +0530 Subject: [PATCH 9/9] fix: lint and build errors --- components/canvas/DesignCanvas.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 14dd672..1b9ea51 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -183,17 +183,11 @@ export function DesignCanvas({ }, [nodes, connections, stateRef]); // Simulation Engine State - const [isSimulationRunning, setIsSimulationRunning] = useState(false); + const [isSimulationRunningRaw, setIsSimulationRunning] = useState(false); + const isSimulationRunning = isSimulationRunningRaw && !readOnly; const [targetRps, setTargetRps] = useState(10000); const simulationMetrics = useSimulationEngine(nodes, connections, targetRps, isSimulationRunning); - // Stop simulation when canvas becomes read-only - useEffect(() => { - if (readOnly && isSimulationRunning) { - setIsSimulationRunning(false); - } - }, [readOnly, isSimulationRunning]); - // Selection state const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedConnectionId, setSelectedConnectionId] = useState(null);