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..f795abe --- /dev/null +++ b/app/api/interview/[id]/chaos-timeout/route.ts @@ -0,0 +1,100 @@ +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 { + const { id } = await context.params; + 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 with ownership check + const session = await InterviewSession.findOne({ id, userId: user._id }); + if (!session) { + 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' }); + } + + // 2. Locate Constraint + const constraintChanges = session.constraintChanges || []; + const constraintIndex = constraintChanges.findIndex((c: IConstraintChange) => 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' || constraint.failedAt || (type === 'warning' && constraint.overtimeAt)) { + 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(); + 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?`, + 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 }); + } +} \ No newline at end of file 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..3206994 --- /dev/null +++ b/app/api/interview/[id]/final-validation/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/src/lib/db/mongoose'; +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, + context: { params: Promise<{ id: string }> } +) { + try { + await dbConnect(); + const { id } = await context.params; + + 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 }); + } + + if (!session.aiMessages) session.aiMessages = []; + + // Idempotency check: Have we already sent it? + const alreadySent = session.aiMessages.some((m: NonNullable[number]) => + 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/api/interview/[id]/hint/route.ts b/app/api/interview/[id]/hint/route.ts index 14b8b97..cd17eb8 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', '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: '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..987adf9 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,40 @@ export default function InterviewCanvasPage({ params }: PageProps) { onConstraintChange: handleConstraintChange }); + const { setMessages } = ai; + + // Final Validation Phase Trigger automatically checks and dispatches. + const finalValidationInFlight = useRef(false); + useEffect(() => { + 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 const fetchSession = useCallback(async () => { if (!user?.uid || !id) return; @@ -284,6 +319,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 (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, setMessages]); + // Loading state if (authLoading || isLoading) { return ( @@ -330,13 +383,14 @@ export default function InterviewCanvasPage({ params }: PageProps) { {/* Submit Error Banner */} @@ -377,6 +431,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { onSave={isReadOnly ? undefined : saveDesign} readOnly={isReadOnly} stateRef={isReadOnly ? undefined : canvasStateRef} + activeConstraints={session.constraintChanges || []} /> {/* Properties Panel */} diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 92bd355..1b9ea51 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -1,6 +1,9 @@ 'use client'; 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 = { @@ -81,6 +84,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 +144,8 @@ export function DesignCanvas({ initialConnections = DEFAULT_CONNECTIONS, onSave, readOnly = false, - stateRef + stateRef, + activeConstraints = [] }: DesignCanvasProps) { const arrowId = useId(); const canvasRef = useRef(null); @@ -176,6 +182,12 @@ export function DesignCanvas({ } }, [nodes, connections, stateRef]); + // Simulation Engine State + const [isSimulationRunningRaw, setIsSimulationRunning] = useState(false); + const isSimulationRunning = isSimulationRunningRaw && !readOnly; + const [targetRps, setTargetRps] = useState(10000); + const simulationMetrics = useSimulationEngine(nodes, connections, targetRps, isSimulationRunning); + // Selection state const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedConnectionId, setSelectedConnectionId] = useState(null); @@ -307,14 +319,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(() => { @@ -414,7 +426,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(); @@ -476,7 +488,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) => { @@ -572,7 +584,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) => { @@ -623,7 +635,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) => { @@ -654,7 +666,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(() => { @@ -669,7 +681,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(() => { @@ -721,6 +733,21 @@ export function DesignCanvas({ onMouseLeave={handleMouseUp} onClick={handleCanvasClick} > + + + {!readOnly && ( + + )} + {/* Grid Background (fixed) */}
@@ -771,16 +798,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 && ( + + )} + ); })} @@ -803,18 +845,57 @@ 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'); + + const nodeMetric = simulationMetrics.nodeMetrics[node.id]; + const isBottlenecked = isSimulationRunning && nodeMetric?.status === 'bottlenecked'; + const isWarning = isSimulationRunning && nodeMetric?.status === 'warning'; + 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); + }} > + {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 && ( )} + + {isImpacted && ( +
+ warning +
+ )} ); -} +} \ No newline at end of file diff --git a/components/canvas/SimulationControls.tsx b/components/canvas/SimulationControls.tsx new file mode 100644 index 0000000..ab63f15 --- /dev/null +++ b/components/canvas/SimulationControls.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +interface SimulationControlsProps { + isRunning: boolean; + targetRps: number; + onToggle: (running: boolean) => void; + onChangeRps: (rps: number) => void; +} + +export function SimulationControls({ + isRunning, + targetRps, + onToggle, + onChangeRps +}: SimulationControlsProps) { + return ( +
+
+
+ + {isRunning ? 'speed' : 'play_arrow'} + + Load Simulator +
+ +
+ +
+
+ Target Throughput + {targetRps.toLocaleString()} RPS +
+ onChangeRps(parseInt(e.target.value, 10))} + 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/components/interview/ChaosTimer.tsx b/components/interview/ChaosTimer.tsx new file mode 100644 index 0000000..8f4e6bd --- /dev/null +++ b/components/interview/ChaosTimer.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; + +interface ChaosTimerProps { + constraint: IConstraintChange; + 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]); + + 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 }; + } + + const normalEnd = new Date(constraint.introducedAt).getTime() + WARNING_MS; + const remaining = normalEnd - now; + + 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()); + + // Track if we have already dispatched the timeout API requests + const hasPendingWarningRequest = useRef(false); + const hasPendingPenaltyRequest = useRef(false); + + useEffect(() => { + // Reset pending request flags for each new active constraint + hasPendingWarningRequest.current = false; + hasPendingPenaltyRequest.current = false; + + const interval = setInterval(() => { + const current = calculateRemaining(); + setState(current); + + if (current.remaining <= 0) { + if (current.mode === 'normal' && !constraint.overtimeAt && !hasPendingWarningRequest.current) { + hasPendingWarningRequest.current = true; + onTimeoutRef.current('warning', constraint.id); + } else if (current.mode === 'overtime' && !constraint.failedAt && !hasPendingPenaltyRequest.current) { + hasPendingPenaltyRequest.current = true; + onTimeoutRef.current('penalty', constraint.id); + } + } + }, 1000); + + return () => clearInterval(interval); + }, [calculateRemaining, constraint]); + + if (state.mode === 'failed') { + return ( +
+ error + Unresolved Incident +
+ ); + } + + 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 isOvertime = state.mode === 'overtime'; + + return ( +
+ warning +
+ + {isOvertime ? 'Final 2 Minutes' : 'Address Failure'} + + + {formatted} + +
+
+ ); +} \ No newline at end of file diff --git a/components/interview/InterviewHeader.tsx b/components/interview/InterviewHeader.tsx index ac26a24..cc4871c 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 */ @@ -24,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 = { @@ -35,13 +38,14 @@ const DIFFICULTY_LABELS: Record = { export function InterviewHeader({ difficulty, constraintChangeCount = 0, - latestConstraintTitle, + latestConstraint, saveStatus, timer, status, onSubmit, isSubmitting = false, sessionId, + onChaosTimeout, }: InterviewHeaderProps) { const diffConfig = DIFFICULTY_LABELS[difficulty] || DIFFICULTY_LABELS.medium; const isInProgress = status === 'in_progress'; @@ -97,7 +101,7 @@ export function InterviewHeader({ {constraintChangeCount > 0 && (
bolt @@ -112,7 +116,10 @@ export function InterviewHeader({ {renderSaveStatus()}
-
+
+ {latestConstraint && latestConstraint.status !== 'addressed' && ( + {})} /> + )}
diff --git a/src/hooks/useInterviewAI.ts b/src/hooks/useInterviewAI.ts index 621bd4c..bc0dfba 100644 --- a/src/hooks/useInterviewAI.ts +++ b/src/hooks/useInterviewAI.ts @@ -149,6 +149,12 @@ export function useInterviewAI({ return { messages, isThinking, - sendReply + 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/hooks/useSimulationEngine.ts b/src/hooks/useSimulationEngine.ts new file mode 100644 index 0000000..78bcae5 --- /dev/null +++ b/src/hooks/useSimulationEngine.ts @@ -0,0 +1,23 @@ +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 +) { + return useMemo(() => { + if (!isRunning || targetRps <= 0) { + return EMPTY_METRICS; + } + return runSimulation(nodes, connections, targetRps); + }, [nodes, connections, targetRps, isRunning]); +} diff --git a/src/lib/db/models/InterviewSession.ts b/src/lib/db/models/InterviewSession.ts index 0bbaddb..cec552a 100644 --- a/src/lib/db/models/InterviewSession.ts +++ b/src/lib/db/models/InterviewSession.ts @@ -37,6 +37,9 @@ export interface IConstraintChange { status: ConstraintChangeStatus; impactAreas: string[]; interviewerMessage: string; + impactedNodeId?: string; + overtimeAt?: Date; + failedAt?: Date; } // Full evaluation result @@ -141,6 +144,9 @@ 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 } ); 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..4f0743a --- /dev/null +++ b/src/lib/simulation/engine.ts @@ -0,0 +1,148 @@ +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]++; + }); + + // 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]) { + nodeMetrics[s.id].trafficIn += targetRps / sources.length; + } + }); + + 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; }); + + // 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; + } + }); + + // Process each node: compute outFlow and distribute to children + nodes.forEach(node => { + const metrics = nodeMetrics[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'; + } 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 (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 + 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 }; +} \ No newline at end of file