ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
399 lines
9.8 KiB
TypeScript
399 lines
9.8 KiB
TypeScript
/**
|
|
* ZCLAW Error Handling Utilities
|
|
*
|
|
* Centralized error reporting, notification, and tracking system.
|
|
*/
|
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
AppError,
|
|
classifyError,
|
|
ErrorCategory,
|
|
ErrorSeverity,
|
|
} from './error-types';
|
|
|
|
// === Types ===
|
|
|
|
export interface StoredError extends AppError {
|
|
dismissed: boolean;
|
|
reported: boolean;
|
|
stack?: string;
|
|
context?: Record<string, unknown>;
|
|
}
|
|
|
|
// === Error Store ===
|
|
|
|
interface ErrorStore {
|
|
errors: StoredError[];
|
|
addError: (error: AppError) => void;
|
|
dismissError: (id: string) => void;
|
|
dismissAll: () => void;
|
|
markReported: (id: string) => void;
|
|
getUndismissedErrors: () => StoredError[];
|
|
getErrorCount: () => number;
|
|
getErrorsByCategory: (category: ErrorCategory) => StoredError[];
|
|
getErrorsBySeverity: (severity: ErrorSeverity) => StoredError[];
|
|
}
|
|
|
|
// === Global Error Store ===
|
|
|
|
let errorStore: ErrorStore = {
|
|
errors: [],
|
|
addError: () => {},
|
|
dismissError: () => {},
|
|
dismissAll: () => {},
|
|
markReported: () => {},
|
|
getUndismissedErrors: () => [],
|
|
getErrorCount: () => 0,
|
|
getErrorsByCategory: () => [],
|
|
getErrorsBySeverity: () => [],
|
|
};
|
|
|
|
// === Initialize Store ===
|
|
|
|
function initErrorStore(): void {
|
|
errorStore = {
|
|
errors: [],
|
|
|
|
addError: (error: AppError) => {
|
|
// Dedup: skip if same title+message already exists and undismissed
|
|
const isDuplicate = errorStore.errors.some(
|
|
(e) => !e.dismissed && e.title === error.title && e.message === error.message
|
|
);
|
|
if (isDuplicate) return;
|
|
|
|
const storedError: StoredError = {
|
|
...error,
|
|
dismissed: false,
|
|
reported: false,
|
|
};
|
|
// Cap at 50 errors to prevent unbounded growth
|
|
errorStore.errors = [storedError, ...errorStore.errors].slice(0, 50);
|
|
// Notify listeners
|
|
notifyErrorListeners(error);
|
|
},
|
|
|
|
dismissError(id: string): void {
|
|
const error = errorStore.errors.find(e => e.id === id);
|
|
if (error) {
|
|
errorStore.errors = errorStore.errors.map(e =>
|
|
e.id === id ? { ...e, dismissed: true } : e
|
|
);
|
|
}
|
|
},
|
|
|
|
dismissAll(): void {
|
|
errorStore.errors = errorStore.errors.map(e => ({ ...e, dismissed: true }));
|
|
},
|
|
|
|
markReported(id: string): void {
|
|
const error = errorStore.errors.find(e => e.id === id);
|
|
if (error) {
|
|
errorStore.errors = errorStore.errors.map(e =>
|
|
e.id === id ? { ...e, reported: true } : e
|
|
);
|
|
}
|
|
},
|
|
|
|
getUndismissedErrors(): StoredError[] {
|
|
return errorStore.errors.filter(e => !e.dismissed);
|
|
},
|
|
|
|
getErrorCount(): number {
|
|
return errorStore.errors.filter(e => !e.dismissed).length;
|
|
},
|
|
|
|
getErrorsByCategory(category: ErrorCategory): StoredError[] {
|
|
return errorStore.errors.filter(e => e.category === category && !e.dismissed);
|
|
},
|
|
|
|
getErrorsBySeverity(severity: ErrorSeverity): StoredError[] {
|
|
return errorStore.errors.filter(e => e.severity === severity && !e.dismissed);
|
|
},
|
|
};
|
|
}
|
|
|
|
// === Error Listeners ===
|
|
|
|
type ErrorListener = (error: AppError) => void;
|
|
const errorListeners: Set<ErrorListener> = new Set();
|
|
|
|
function addErrorListener(listener: ErrorListener): () => void {
|
|
errorListeners.add(listener);
|
|
return () => errorListeners.delete(listener);
|
|
}
|
|
|
|
function notifyErrorListeners(error: AppError): void {
|
|
errorListeners.forEach(listener => {
|
|
try {
|
|
listener(error);
|
|
} catch (e) {
|
|
console.error('[ErrorHandling] Listener error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize on first import
|
|
initErrorStore();
|
|
|
|
// === Public API ===
|
|
|
|
/**
|
|
* Report an error to the centralized error handling system.
|
|
*/
|
|
export function reportError(
|
|
error: unknown,
|
|
context?: {
|
|
componentStack?: string;
|
|
errorName?: string;
|
|
errorMessage?: string;
|
|
}
|
|
): AppError {
|
|
const appError = classifyError(error);
|
|
|
|
// Add context information if provided
|
|
if (context) {
|
|
const technicalDetails = [
|
|
context.componentStack && `Component Stack:\n${context.componentStack}`,
|
|
context.errorName && `Error Name: ${context.errorName}`,
|
|
context.errorMessage && `Error Message: ${context.errorMessage}`,
|
|
].filter(Boolean).join('\n\n');
|
|
|
|
if (technicalDetails) {
|
|
(appError as { technicalDetails?: string }).technicalDetails = technicalDetails;
|
|
}
|
|
}
|
|
|
|
errorStore.addError(appError);
|
|
|
|
// Log to console in development
|
|
if (import.meta.env.DEV) {
|
|
console.error('[ErrorHandling] Error reported:', {
|
|
id: appError.id,
|
|
category: appError.category,
|
|
severity: appError.severity,
|
|
title: appError.title,
|
|
message: appError.message,
|
|
});
|
|
}
|
|
|
|
return appError;
|
|
}
|
|
|
|
/**
|
|
* Report an error from an API response.
|
|
*/
|
|
export function reportApiError(
|
|
response: Response,
|
|
endpoint: string,
|
|
method: string = 'GET'
|
|
): AppError {
|
|
const status = response.status;
|
|
let category: ErrorCategory = 'server';
|
|
let severity: ErrorSeverity = 'medium';
|
|
let title = 'API Error';
|
|
let message = `Request to ${endpoint} failed with status ${status}`;
|
|
let recoverySteps: { description: string }[] = [];
|
|
|
|
if (status === 401) {
|
|
category = 'auth';
|
|
severity = 'high';
|
|
title = 'Authentication Required';
|
|
message = 'Your session has expired. Please authenticate again.';
|
|
recoverySteps = [
|
|
{ description: 'Click "Reconnect" to authenticate' },
|
|
{ description: 'Check your API key in settings' },
|
|
];
|
|
} else if (status === 403) {
|
|
category = 'permission';
|
|
severity = 'medium';
|
|
title = 'Permission Denied';
|
|
message = 'You do not have permission to perform this action.';
|
|
recoverySteps = [
|
|
{ description: 'Contact your administrator for access' },
|
|
{ description: 'Check your RBAC configuration' },
|
|
];
|
|
} else if (status === 404) {
|
|
category = 'client';
|
|
severity = 'low';
|
|
title = 'Not Found';
|
|
message = `The requested resource was not found: ${endpoint}`;
|
|
recoverySteps = [
|
|
{ description: 'Verify the resource exists' },
|
|
{ description: 'Check the URL is correct' },
|
|
];
|
|
} else if (status === 422) {
|
|
category = 'validation';
|
|
severity = 'low';
|
|
title = 'Validation Error';
|
|
message = 'The request data is invalid.';
|
|
recoverySteps = [
|
|
{ description: 'Check your input data format' },
|
|
{ description: 'Verify required fields are provided' },
|
|
];
|
|
} else if (status === 429) {
|
|
category = 'client';
|
|
severity = 'medium';
|
|
title = 'Rate Limited';
|
|
message = 'Too many requests. Please wait before trying again.';
|
|
recoverySteps = [
|
|
{ description: 'Wait a moment before retrying' },
|
|
{ description: 'Reduce request frequency' },
|
|
];
|
|
} else if (status >= 500) {
|
|
category = 'server';
|
|
severity = 'high';
|
|
title = 'Server Error';
|
|
message = 'The server encountered an error processing your request.';
|
|
recoverySteps = [
|
|
{ description: 'Try again in a few moments' },
|
|
{ description: 'Contact support if the problem persists' },
|
|
];
|
|
}
|
|
|
|
const appError: AppError = {
|
|
id: uuidv4(),
|
|
category,
|
|
severity,
|
|
title,
|
|
message,
|
|
technicalDetails: `${method} ${endpoint}\nStatus: ${status}\nResponse: ${response.statusText}`,
|
|
recoverable: status !== 500 || status < 400,
|
|
recoverySteps,
|
|
timestamp: new Date(),
|
|
originalError: response,
|
|
};
|
|
|
|
errorStore.addError(appError);
|
|
return appError;
|
|
}
|
|
|
|
/**
|
|
* Report a network error.
|
|
*/
|
|
export function reportNetworkError(
|
|
error: Error,
|
|
url?: string
|
|
): AppError {
|
|
return reportError(error, {
|
|
errorMessage: url ? `URL: ${url}\n${error.message}` : error.message,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Report a WebSocket error.
|
|
*/
|
|
export function reportWebSocketError(
|
|
event: CloseEvent | ErrorEvent,
|
|
url: string
|
|
): AppError {
|
|
const code = 'code' in event ? event.code : 0;
|
|
const reason = 'reason' in event ? event.reason : 'Unknown';
|
|
|
|
return reportError(
|
|
new Error(`WebSocket error: ${reason} (code: ${code})`),
|
|
{
|
|
errorMessage: `WebSocket URL: ${url}\nCode: ${code}\nReason: ${reason}`,
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Dismiss an error by ID.
|
|
*/
|
|
export function dismissError(id: string): void {
|
|
errorStore.dismissError(id);
|
|
}
|
|
|
|
/**
|
|
* Dismiss all active errors.
|
|
*/
|
|
export function dismissAllErrors(): void {
|
|
errorStore.dismissAll();
|
|
}
|
|
|
|
/**
|
|
* Dismiss all active errors (alias for dismissAllErrors).
|
|
*/
|
|
export function dismissAll(): void {
|
|
errorStore.dismissAll();
|
|
}
|
|
|
|
/**
|
|
* Mark an error as reported.
|
|
*/
|
|
export function markErrorReported(id: string): void {
|
|
errorStore.markReported(id);
|
|
}
|
|
|
|
/**
|
|
* Get all active (non-dismissed) errors.
|
|
*/
|
|
export function getActiveErrors(): StoredError[] {
|
|
return errorStore.getUndismissedErrors();
|
|
}
|
|
|
|
/**
|
|
* Get all undismissed errors (alias for getActiveErrors).
|
|
*/
|
|
export function getUndismissedErrors(): StoredError[] {
|
|
return errorStore.getUndismissedErrors();
|
|
}
|
|
|
|
/**
|
|
* Get the count of active errors.
|
|
*/
|
|
export function getActiveErrorCount(): number {
|
|
return errorStore.getErrorCount();
|
|
}
|
|
|
|
/**
|
|
* Get errors filtered by category.
|
|
*/
|
|
export function getErrorsByCategory(category: ErrorCategory): StoredError[] {
|
|
return errorStore.getErrorsByCategory(category);
|
|
}
|
|
|
|
/**
|
|
* Get errors filtered by severity.
|
|
*/
|
|
export function getErrorsBySeverity(severity: ErrorSeverity): StoredError[] {
|
|
return errorStore.getErrorsBySeverity(severity);
|
|
}
|
|
|
|
/**
|
|
* Subscribe to error events.
|
|
*/
|
|
export function subscribeToErrors(listener: ErrorListener): () => void {
|
|
return addErrorListener(listener);
|
|
}
|
|
|
|
/**
|
|
* Check if there are any critical errors.
|
|
*/
|
|
export function hasCriticalErrors(): boolean {
|
|
return errorStore.getErrorsBySeverity('critical').length > 0;
|
|
}
|
|
|
|
/**
|
|
* Check if there are any high severity errors.
|
|
*/
|
|
export function hasHighSeverityErrors(): boolean {
|
|
const highSeverity = ['high', 'critical'];
|
|
return errorStore.errors.some(e => highSeverity.includes(e.severity) && !e.dismissed);
|
|
}
|
|
|
|
// === Types ===
|
|
|
|
interface CloseEvent {
|
|
code?: number;
|
|
reason?: string;
|
|
wasClean?: boolean;
|
|
}
|
|
|
|
interface ErrorEvent {
|
|
code?: number;
|
|
reason?: string;
|
|
message?: string;
|
|
}
|