@firela/billclaw-core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Error handling utilities for BillClaw
3
3
  *
4
- * Provides user-friendly error messages, recovery suggestions,
5
- * and troubleshooting guides.
4
+ * Provides dual-mode error handling:
5
+ * - Machine-readable: For AI agents (error codes, severity, executable actions)
6
+ * - Human-readable: For display (title, message, suggestions)
6
7
  *
7
8
  * Framework-agnostic: Logger is provided via runtime abstraction.
8
9
  */
@@ -29,45 +30,121 @@ export var ErrorCategory;
29
30
  ErrorCategory["UNKNOWN"] = "unknown";
30
31
  })(ErrorCategory || (ErrorCategory = {}));
31
32
  /**
32
- * Create a user-friendly error
33
+ * Error codes for programmatic error handling
34
+ * Used by AI agents for switch/case logic and decision making
33
35
  */
34
- export function createUserError(category, title, message, suggestions, docsLink, originalError) {
36
+ export const ERROR_CODES = {
37
+ // Lock errors
38
+ LOCK_ACQUISITION_FAILED: "LOCK_ACQUISITION_FAILED",
39
+ LOCK_TIMEOUT: "LOCK_TIMEOUT",
40
+ LOCK_STALE: "LOCK_STALE",
41
+ // Credential errors
42
+ CREDENTIALS_NOT_FOUND: "CREDENTIALS_NOT_FOUND",
43
+ CREDENTIALS_STORAGE_FAILED: "CREDENTIALS_STORAGE_FAILED",
44
+ CREDENTIALS_KEYCHAIN_FAILED: "CREDENTIALS_KEYCHAIN_FAILED",
45
+ // Network errors
46
+ NETWORK_CONNECTION_REFUSED: "NETWORK_CONNECTION_REFUSED",
47
+ NETWORK_TIMEOUT: "NETWORK_TIMEOUT",
48
+ NETWORK_DNS_FAILED: "NETWORK_DNS_FAILED",
49
+ NETWORK_GENERIC: "NETWORK_GENERIC",
50
+ // Plaid errors
51
+ PLAID_ITEM_LOGIN_REQUIRED: "PLAID_ITEM_LOGIN_REQUIRED",
52
+ PLAID_INVALID_ACCESS_TOKEN: "PLAID_INVALID_ACCESS_TOKEN",
53
+ PLAID_PRODUCT_NOT_READY: "PLAID_PRODUCT_NOT_READY",
54
+ PLAID_RATE_LIMIT_EXCEEDED: "PLAID_RATE_LIMIT_EXCEEDED",
55
+ PLAID_INSTITUTION_DOWN: "PLAID_INSTITUTION_DOWN",
56
+ PLAID_INVALID_CREDENTIALS: "PLAID_INVALID_CREDENTIALS",
57
+ PLAID_API_ERROR: "PLAID_API_ERROR",
58
+ // Gmail errors
59
+ GMAIL_AUTH_FAILED: "GMAIL_AUTH_FAILED",
60
+ GMAIL_ACCESS_DENIED: "GMAIL_ACCESS_DENIED",
61
+ GMAIL_API_NOT_FOUND: "GMAIL_API_NOT_FOUND",
62
+ GMAIL_RATE_LIMIT_EXCEEDED: "GMAIL_RATE_LIMIT_EXCEEDED",
63
+ GMAIL_API_ERROR: "GMAIL_API_ERROR",
64
+ // Storage errors
65
+ STORAGE_DISK_FULL: "STORAGE_DISK_FULL",
66
+ STORAGE_WRITE_FAILED: "STORAGE_WRITE_FAILED",
67
+ STORAGE_READ_FAILED: "STORAGE_READ_FAILED",
68
+ // File system errors
69
+ FS_PERMISSION_DENIED: "FS_PERMISSION_DENIED",
70
+ FS_NOT_FOUND: "FS_NOT_FOUND",
71
+ FS_GENERIC: "FS_GENERIC",
72
+ // Config errors
73
+ CONFIG_INVALID: "CONFIG_INVALID",
74
+ CONFIG_MISSING: "CONFIG_MISSING",
75
+ CONFIG_PARSE_FAILED: "CONFIG_PARSE_FAILED",
76
+ // Generic
77
+ UNKNOWN_ERROR: "UNKNOWN_ERROR",
78
+ };
79
+ /**
80
+ * Create a user-friendly error with dual-mode support
81
+ *
82
+ * @param errorCode - Machine-readable error code
83
+ * @param category - Error category
84
+ * @param severity - Error severity level
85
+ * @param recoverable - Whether the error is recoverable
86
+ * @param humanReadable - Human-readable error information
87
+ * @param nextActions - AI-executable recovery actions
88
+ * @param entities - Structured entity references
89
+ * @param originalError - Original error for debugging
90
+ */
91
+ export function createUserError(errorCode, category, severity, recoverable, humanReadable, nextActions, entities, originalError) {
35
92
  return {
36
93
  type: "UserError",
94
+ errorCode,
37
95
  category,
38
- title,
39
- message,
40
- suggestions,
41
- docsLink,
42
- error: originalError,
96
+ severity,
97
+ recoverable,
98
+ humanReadable,
99
+ nextActions,
100
+ entities,
101
+ originalError,
102
+ timestamp: new Date().toISOString(),
43
103
  };
44
104
  }
45
105
  /**
46
106
  * Format error for display to user
107
+ * Uses the humanReadable field for user-friendly output
47
108
  */
48
109
  export function formatError(error) {
49
110
  const lines = [];
50
- // Header with category emoji
111
+ // Header with category emoji and severity
51
112
  const categoryEmoji = getCategoryEmoji(error.category);
52
- lines.push(`${categoryEmoji} ${error.title}`);
113
+ const severityIndicator = getSeverityIndicator(error.severity);
114
+ lines.push(`${categoryEmoji} ${error.humanReadable.title} ${severityIndicator}`);
53
115
  lines.push("");
54
116
  // Message
55
- lines.push(error.message);
117
+ lines.push(error.humanReadable.message);
56
118
  lines.push("");
57
119
  // Suggestions
58
- if (error.suggestions.length > 0) {
120
+ if (error.humanReadable.suggestions.length > 0) {
59
121
  lines.push("Suggestions:");
60
- for (let i = 0; i < error.suggestions.length; i++) {
61
- lines.push(` ${i + 1}. ${error.suggestions[i]}`);
122
+ for (let i = 0; i < error.humanReadable.suggestions.length; i++) {
123
+ lines.push(` ${i + 1}. ${error.humanReadable.suggestions[i]}`);
62
124
  }
63
125
  }
64
126
  // Docs link
65
- if (error.docsLink) {
127
+ if (error.humanReadable.docsLink) {
66
128
  lines.push("");
67
- lines.push(`Learn more: ${error.docsLink}`);
129
+ lines.push(`Learn more: ${error.humanReadable.docsLink}`);
68
130
  }
131
+ // Error code (for debugging/Support)
132
+ lines.push("");
133
+ lines.push(`Error code: ${error.errorCode}`);
69
134
  return lines.join("\n");
70
135
  }
136
+ /**
137
+ * Get severity indicator for display
138
+ */
139
+ function getSeverityIndicator(severity) {
140
+ const indicators = {
141
+ fatal: "🔴",
142
+ error: "❌",
143
+ warning: "⚠️",
144
+ info: "ℹ️",
145
+ };
146
+ return indicators[severity] || "";
147
+ }
71
148
  /**
72
149
  * Get emoji for error category
73
150
  */
@@ -90,107 +167,245 @@ function getCategoryEmoji(category) {
90
167
  /**
91
168
  * Parse Plaid error codes and create user-friendly errors
92
169
  */
93
- export function parsePlaidError(error) {
170
+ export function parsePlaidError(error, accountId) {
94
171
  const errorCode = error.error_code || "UNKNOWN";
95
172
  const errorMessage = error.error_message || error.display_message || "An error occurred";
96
173
  const requestId = error.request_id;
97
174
  // Login required
98
175
  if (errorCode === "ITEM_LOGIN_REQUIRED" ||
99
176
  error.error_type === "ITEM_LOGIN_REQUIRED") {
100
- return createUserError(ErrorCategory.PLAID_AUTH, "Account Re-Authentication Required", "Your bank account requires re-authentication. This happens when your bank credentials have changed or expired.", [
101
- "Re-authenticate via your adapter's setup command",
102
- "This will open a secure browser window where you can log into your bank",
103
- "After re-authentication, your transactions will sync normally",
104
- ], "https://plaid.com/docs/errors/#item-login-required");
177
+ return createUserError(ERROR_CODES.PLAID_ITEM_LOGIN_REQUIRED, ErrorCategory.PLAID_AUTH, "error", true, {
178
+ title: "Account Re-Authentication Required",
179
+ message: "Your bank account requires re-authentication. This happens when your bank credentials have changed or expired.",
180
+ suggestions: [
181
+ "Re-authenticate via your adapter's setup command",
182
+ "This will open a secure browser window where you can log into your bank",
183
+ "After re-authentication, your transactions will sync normally",
184
+ ],
185
+ docsLink: "https://plaid.com/docs/errors/#item-login-required",
186
+ }, [
187
+ {
188
+ type: "oauth_reauth",
189
+ tool: "plaid_oauth",
190
+ params: { accountId, item_id: error.item_id },
191
+ description: "Trigger OAuth re-authentication flow",
192
+ },
193
+ ], { accountId, itemId: error.item_id, institutionId: error.institution_id });
105
194
  }
106
195
  // Invalid credentials
107
196
  if (errorCode === "INVALID_ACCESS_TOKEN" ||
108
197
  error.error_type === "INVALID_ACCESS_TOKEN") {
109
- return createUserError(ErrorCategory.PLAID_AUTH, "Invalid Access Token", "Your access token is invalid. This can happen if the token was revoked or corrupted.", [
110
- "Run your adapter's setup command to reconnect your account",
111
- "If this persists, remove and re-add the account",
112
- ], "https://plaid.com/docs/errors/#invalid-access-token");
198
+ return createUserError(ERROR_CODES.PLAID_INVALID_ACCESS_TOKEN, ErrorCategory.PLAID_AUTH, "error", true, {
199
+ title: "Invalid Access Token",
200
+ message: "Your access token is invalid. This can happen if the token was revoked or corrupted.",
201
+ suggestions: [
202
+ "Run your adapter's setup command to reconnect your account",
203
+ "If this persists, remove and re-add the account",
204
+ ],
205
+ docsLink: "https://plaid.com/docs/errors/#invalid-access-token",
206
+ }, [
207
+ {
208
+ type: "oauth_reauth",
209
+ tool: "plaid_oauth",
210
+ params: { accountId },
211
+ description: "Re-authenticate to get a new access token",
212
+ },
213
+ ], { accountId, itemId: error.item_id });
113
214
  }
114
215
  // Product not ready
115
216
  if (errorCode === "PRODUCT_NOT_READY") {
116
- return createUserError(ErrorCategory.PLAID_API, "Account Not Ready", "Your account is not fully set up yet. Plaid is still processing your account information.", [
117
- "Wait a few minutes and try again",
118
- "If this persists, contact Plaid support",
119
- ], "https://plaid.com/docs/errors/#product-not-ready");
217
+ return createUserError(ERROR_CODES.PLAID_PRODUCT_NOT_READY, ErrorCategory.PLAID_API, "warning", true, {
218
+ title: "Account Not Ready",
219
+ message: "Your account is not fully set up yet. Plaid is still processing your account information.",
220
+ suggestions: [
221
+ "Wait a few minutes and try again",
222
+ "If this persists, contact Plaid support",
223
+ ],
224
+ docsLink: "https://plaid.com/docs/errors/#product-not-ready",
225
+ }, [
226
+ {
227
+ type: "retry",
228
+ delayMs: 60000,
229
+ description: "Retry after 1 minute",
230
+ },
231
+ ], { accountId, itemId: error.item_id });
120
232
  }
121
233
  // Rate limit
122
234
  if (errorCode === "RATE_LIMIT_EXCEEDED") {
123
- return createUserError(ErrorCategory.PLAID_API, "API Rate Limit Exceeded", "Too many requests have been made to the Plaid API. Please wait before trying again.", [
124
- "Wait a few minutes before syncing again",
125
- "Consider syncing less frequently (e.g., daily instead of hourly)",
126
- "If you need higher rate limits, upgrade your Plaid plan",
127
- ], "https://plaid.com/docs/errors/#rate-limit-exceeded");
235
+ return createUserError(ERROR_CODES.PLAID_RATE_LIMIT_EXCEEDED, ErrorCategory.PLAID_API, "warning", true, {
236
+ title: "API Rate Limit Exceeded",
237
+ message: "Too many requests have been made to the Plaid API. Please wait before trying again.",
238
+ suggestions: [
239
+ "Wait a few minutes before syncing again",
240
+ "Consider syncing less frequently (e.g., daily instead of hourly)",
241
+ "If you need higher rate limits, upgrade your Plaid plan",
242
+ ],
243
+ docsLink: "https://plaid.com/docs/errors/#rate-limit-exceeded",
244
+ }, [
245
+ {
246
+ type: "retry",
247
+ delayMs: 300000, // 5 minutes
248
+ description: "Retry after 5 minutes",
249
+ },
250
+ ], { accountId });
128
251
  }
129
252
  // Institution down
130
253
  if (errorCode === "INSTITUTION_DOWN") {
131
- return createUserError(ErrorCategory.PLAID_API, "Bank temporarily unavailable", "Your bank's systems are temporarily down for maintenance.", [
132
- "Wait a few minutes and try again",
133
- "Check your bank's website for service status updates",
134
- "Your transactions will sync automatically once the bank is back online",
135
- ], undefined);
254
+ return createUserError(ERROR_CODES.PLAID_INSTITUTION_DOWN, ErrorCategory.PLAID_API, "warning", true, {
255
+ title: "Bank temporarily unavailable",
256
+ message: "Your bank's systems are temporarily down for maintenance.",
257
+ suggestions: [
258
+ "Wait a few minutes and try again",
259
+ "Check your bank's website for service status updates",
260
+ "Your transactions will sync automatically once the bank is back online",
261
+ ],
262
+ }, [
263
+ {
264
+ type: "retry",
265
+ delayMs: 300000, // 5 minutes
266
+ description: "Retry after 5 minutes",
267
+ },
268
+ ], {
269
+ accountId,
270
+ institutionId: error.institution_id,
271
+ });
136
272
  }
137
273
  // Invalid credentials
138
274
  if (errorCode === "INVALID_CREDENTIALS") {
139
- return createUserError(ErrorCategory.PLAID_API, "Invalid API Credentials", "The Plaid API credentials configured are invalid.", [
140
- "Configure your Plaid client ID and secret",
141
- "Verify your credentials at https://dashboard.plaid.com",
142
- ], "https://dashboard.plaid.com");
275
+ return createUserError(ERROR_CODES.PLAID_INVALID_CREDENTIALS, ErrorCategory.PLAID_API, "error", false, {
276
+ title: "Invalid API Credentials",
277
+ message: "The Plaid API credentials configured are invalid.",
278
+ suggestions: [
279
+ "Configure your Plaid client ID and secret",
280
+ "Verify your credentials at https://dashboard.plaid.com",
281
+ ],
282
+ docsLink: "https://dashboard.plaid.com",
283
+ }, [
284
+ {
285
+ type: "config_change",
286
+ params: { setting: "plaid_credentials" },
287
+ description: "Update Plaid API credentials",
288
+ },
289
+ ], {});
143
290
  }
144
291
  // Generic Plaid error
145
- return createUserError(ErrorCategory.PLAID_API, "Plaid API Error", `${errorMessage}${requestId ? ` (Request ID: ${requestId})` : ""}`, [
146
- "Try again in a few minutes",
147
- "Check Plaid status at https://status.plaid.com",
148
- ], "https://plaid.com/docs/errors/");
292
+ return createUserError(ERROR_CODES.PLAID_API_ERROR, ErrorCategory.PLAID_API, "error", true, {
293
+ title: "Plaid API Error",
294
+ message: `${errorMessage}${requestId ? ` (Request ID: ${requestId})` : ""}`,
295
+ suggestions: [
296
+ "Try again in a few minutes",
297
+ "Check Plaid status at https://status.plaid.com",
298
+ ],
299
+ docsLink: "https://plaid.com/docs/errors/",
300
+ }, [
301
+ {
302
+ type: "retry",
303
+ delayMs: 60000,
304
+ description: "Retry after 1 minute",
305
+ },
306
+ ], { accountId, itemId: error.item_id });
149
307
  }
150
308
  /**
151
309
  * Parse Gmail API errors and create user-friendly errors
152
310
  */
153
- export function parseGmailError(error) {
311
+ export function parseGmailError(error, accountId) {
154
312
  const statusCode = error.status || error.code || 0;
155
313
  // Unauthorized
156
314
  if (statusCode === 401) {
157
- return createUserError(ErrorCategory.GMAIL_AUTH, "Gmail Authentication Failed", "Your Gmail access has expired or been revoked. You need to re-authenticate.", [
158
- "Re-authenticate with Gmail via your adapter's setup command",
159
- "Make sure you grant read-only access to your Gmail",
160
- "Check that Google Cloud OAuth credentials are valid",
161
- ], "https://developers.google.com/gmail/api/auth");
315
+ return createUserError(ERROR_CODES.GMAIL_AUTH_FAILED, ErrorCategory.GMAIL_AUTH, "error", true, {
316
+ title: "Gmail Authentication Failed",
317
+ message: "Your Gmail access has expired or been revoked. You need to re-authenticate.",
318
+ suggestions: [
319
+ "Re-authenticate with Gmail via your adapter's setup command",
320
+ "Make sure you grant read-only access to your Gmail",
321
+ "Check that Google Cloud OAuth credentials are valid",
322
+ ],
323
+ docsLink: "https://developers.google.com/gmail/api/auth",
324
+ }, [
325
+ {
326
+ type: "oauth_reauth",
327
+ tool: "gmail_oauth",
328
+ params: { accountId },
329
+ description: "Re-authenticate with Gmail",
330
+ },
331
+ ], { accountId });
162
332
  }
163
333
  // Forbidden
164
334
  if (statusCode === 403) {
165
- return createUserError(ErrorCategory.GMAIL_API, "Gmail Access Denied", "Access to Gmail was denied. This usually means the OAuth token lacks the required permissions.", [
166
- "Make sure the Gmail API is enabled in Google Cloud Console",
167
- "Verify that the OAuth consent screen includes 'gmail.readonly' scope",
168
- "Re-authenticate to grant proper permissions",
169
- ], "https://developers.google.com/gmail/api/auth");
335
+ return createUserError(ERROR_CODES.GMAIL_ACCESS_DENIED, ErrorCategory.GMAIL_API, "error", false, {
336
+ title: "Gmail Access Denied",
337
+ message: "Access to Gmail was denied. This usually means the OAuth token lacks the required permissions.",
338
+ suggestions: [
339
+ "Make sure the Gmail API is enabled in Google Cloud Console",
340
+ "Verify that the OAuth consent screen includes 'gmail.readonly' scope",
341
+ "Re-authenticate to grant proper permissions",
342
+ ],
343
+ docsLink: "https://developers.google.com/gmail/api/auth",
344
+ }, [
345
+ {
346
+ type: "oauth_reauth",
347
+ tool: "gmail_oauth",
348
+ params: { accountId },
349
+ description: "Re-authenticate with proper permissions",
350
+ },
351
+ ], { accountId });
170
352
  }
171
353
  // Not found
172
354
  if (statusCode === 404) {
173
- return createUserError(ErrorCategory.GMAIL_API, "Gmail API Not Found", "The Gmail API endpoint could not be found. This may be a configuration issue.", [
174
- "Verify the Gmail API is enabled in your Google Cloud project",
175
- "Check that the API name is correct: 'gmail.api'",
176
- "Try re-enabling the Gmail API in Google Cloud Console",
177
- ], "https://console.cloud.google.com/apis/library/gmail-api");
355
+ return createUserError(ERROR_CODES.GMAIL_API_NOT_FOUND, ErrorCategory.GMAIL_API, "error", false, {
356
+ title: "Gmail API Not Found",
357
+ message: "The Gmail API endpoint could not be found. This may be a configuration issue.",
358
+ suggestions: [
359
+ "Verify the Gmail API is enabled in your Google Cloud project",
360
+ "Check that the API name is correct: 'gmail.api'",
361
+ "Try re-enabling the Gmail API in Google Cloud Console",
362
+ ],
363
+ docsLink: "https://console.cloud.google.com/apis/library/gmail-api",
364
+ }, [
365
+ {
366
+ type: "config_change",
367
+ params: { setting: "gmail_api_enabled" },
368
+ description: "Enable Gmail API in Google Cloud Console",
369
+ },
370
+ ], {});
178
371
  }
179
372
  // Rate limit
180
373
  if (statusCode === 429) {
181
- return createUserError(ErrorCategory.GMAIL_API, "Gmail API Rate Limit Exceeded", "Too many requests have been made to the Gmail API. You've hit the daily quota limit.", [
182
- "Wait until tomorrow when the quota resets",
183
- "Reduce sync frequency to avoid hitting the limit",
184
- "Consider using Gmail push notifications instead of polling",
185
- "Free tier: 250 quota units/day",
186
- ], "https://developers.google.com/gmail/api/v1/quota");
374
+ return createUserError(ERROR_CODES.GMAIL_RATE_LIMIT_EXCEEDED, ErrorCategory.GMAIL_API, "warning", true, {
375
+ title: "Gmail API Rate Limit Exceeded",
376
+ message: "Too many requests have been made to the Gmail API. You've hit the daily quota limit.",
377
+ suggestions: [
378
+ "Wait until tomorrow when the quota resets",
379
+ "Reduce sync frequency to avoid hitting the limit",
380
+ "Consider using Gmail push notifications instead of polling",
381
+ "Free tier: 250 quota units/day",
382
+ ],
383
+ docsLink: "https://developers.google.com/gmail/api/v1/quota",
384
+ }, [
385
+ {
386
+ type: "wait",
387
+ description: "Wait until quota resets (usually daily)",
388
+ },
389
+ ], { accountId });
187
390
  }
188
391
  // Generic Gmail error
189
- return createUserError(ErrorCategory.GMAIL_API, "Gmail API Error", error.message || "An error occurred while communicating with Gmail", [
190
- "Check your internet connection",
191
- "Verify Gmail API is enabled in Google Cloud Console",
192
- "Try again in a few minutes",
193
- ], "https://developers.google.com/gmail/api");
392
+ return createUserError(ERROR_CODES.GMAIL_API_ERROR, ErrorCategory.GMAIL_API, "error", true, {
393
+ title: "Gmail API Error",
394
+ message: error.message ||
395
+ "An error occurred while communicating with Gmail",
396
+ suggestions: [
397
+ "Check your internet connection",
398
+ "Verify Gmail API is enabled in Google Cloud Console",
399
+ "Try again in a few minutes",
400
+ ],
401
+ docsLink: "https://developers.google.com/gmail/api",
402
+ }, [
403
+ {
404
+ type: "retry",
405
+ delayMs: 60000,
406
+ description: "Retry after 1 minute",
407
+ },
408
+ ], { accountId });
194
409
  }
195
410
  /**
196
411
  * Parse network errors
@@ -200,36 +415,76 @@ export function parseNetworkError(error) {
200
415
  // Connection refused
201
416
  if (message.includes("econnrefused") ||
202
417
  message.includes("connection refused")) {
203
- return createUserError(ErrorCategory.NETWORK, "Connection Refused", "Could not connect to the server. The service may be down or your network is blocking the connection.", [
204
- "Check your internet connection",
205
- "Verify you're not behind a firewall or proxy",
206
- "If using a VPN, try disconnecting it",
207
- "Check if the service is temporarily down",
208
- ], undefined);
418
+ return createUserError(ERROR_CODES.NETWORK_CONNECTION_REFUSED, ErrorCategory.NETWORK, "error", true, {
419
+ title: "Connection Refused",
420
+ message: "Could not connect to the server. The service may be down or your network is blocking the connection.",
421
+ suggestions: [
422
+ "Check your internet connection",
423
+ "Verify you're not behind a firewall or proxy",
424
+ "If using a VPN, try disconnecting it",
425
+ "Check if the service is temporarily down",
426
+ ],
427
+ }, [
428
+ {
429
+ type: "retry",
430
+ delayMs: 30000,
431
+ description: "Retry after 30 seconds",
432
+ },
433
+ ], {}, error);
209
434
  }
210
435
  // Timeout
211
436
  if (message.includes("timeout") || message.includes("timed out")) {
212
- return createUserError(ErrorCategory.NETWORK, "Request Timeout", "The request took too long to complete. This could be due to slow network or server issues.", [
213
- "Check your internet connection speed",
214
- "Try again in a few minutes",
215
- "If syncing many transactions, consider reducing the date range",
216
- ], undefined);
437
+ return createUserError(ERROR_CODES.NETWORK_TIMEOUT, ErrorCategory.NETWORK, "warning", true, {
438
+ title: "Request Timeout",
439
+ message: "The request took too long to complete. This could be due to slow network or server issues.",
440
+ suggestions: [
441
+ "Check your internet connection speed",
442
+ "Try again in a few minutes",
443
+ "If syncing many transactions, consider reducing the date range",
444
+ ],
445
+ }, [
446
+ {
447
+ type: "retry",
448
+ delayMs: 60000,
449
+ description: "Retry after 1 minute",
450
+ },
451
+ ], {}, error);
217
452
  }
218
453
  // DNS resolution failed
219
454
  if (message.includes("enotfound") || message.includes("getaddrinfo")) {
220
- return createUserError(ErrorCategory.NETWORK, "DNS Resolution Failed", "Could not resolve the server address. This might be a DNS or network configuration issue.", [
221
- "Check your internet connection",
222
- "Try switching to a different DNS server (e.g., 8.8.8.8)",
223
- "Flush your DNS cache",
224
- "If you're using a VPN, try disconnecting it",
225
- ], undefined);
455
+ return createUserError(ERROR_CODES.NETWORK_DNS_FAILED, ErrorCategory.NETWORK, "error", false, {
456
+ title: "DNS Resolution Failed",
457
+ message: "Could not resolve the server address. This might be a DNS or network configuration issue.",
458
+ suggestions: [
459
+ "Check your internet connection",
460
+ "Try switching to a different DNS server (e.g., 8.8.8.8)",
461
+ "Flush your DNS cache",
462
+ "If you're using a VPN, try disconnecting it",
463
+ ],
464
+ }, [
465
+ {
466
+ type: "manual_intervention",
467
+ description: "Manual network configuration may be required",
468
+ },
469
+ ], {}, error);
226
470
  }
227
471
  // Generic network error
228
- return createUserError(ErrorCategory.NETWORK, "Network Error", error.message || "An error occurred while communicating with the server", [
229
- "Check your internet connection",
230
- "Try again in a few minutes",
231
- "If the problem persists, check your network settings",
232
- ], undefined);
472
+ return createUserError(ERROR_CODES.NETWORK_GENERIC, ErrorCategory.NETWORK, "error", true, {
473
+ title: "Network Error",
474
+ message: error.message ||
475
+ "An error occurred while communicating with the server",
476
+ suggestions: [
477
+ "Check your internet connection",
478
+ "Try again in a few minutes",
479
+ "If the problem persists, check your network settings",
480
+ ],
481
+ }, [
482
+ {
483
+ type: "retry",
484
+ delayMs: 60000,
485
+ description: "Retry after 1 minute",
486
+ },
487
+ ], {}, error);
233
488
  }
234
489
  /**
235
490
  * Parse file system errors
@@ -239,31 +494,69 @@ export function parseFileSystemError(error, filePath) {
239
494
  const message = error.message;
240
495
  // Permission denied
241
496
  if (code === "EACCES" || code === "EPERM") {
242
- return createUserError(ErrorCategory.FILE_SYSTEM, "Permission Denied", `Cannot access ${filePath || "file or directory"}. You don't have the required permissions.`, [
243
- "Check file/directory permissions",
244
- "Ensure the user has read/write access to the data directory",
245
- ], undefined);
497
+ return createUserError(ERROR_CODES.FS_PERMISSION_DENIED, ErrorCategory.FILE_SYSTEM, "error", false, {
498
+ title: "Permission Denied",
499
+ message: `Cannot access ${filePath || "file or directory"}. You don't have the required permissions.`,
500
+ suggestions: [
501
+ "Check file/directory permissions",
502
+ "Ensure the user has read/write access to the data directory",
503
+ ],
504
+ }, [
505
+ {
506
+ type: "manual_intervention",
507
+ description: "Fix file/directory permissions manually",
508
+ },
509
+ ], { filePath }, error);
246
510
  }
247
511
  // No space left
248
512
  if (code === "ENOSPC") {
249
- return createUserError(ErrorCategory.STORAGE, "Disk Full", "No space left on device. Cannot save transactions.", [
250
- "Free up disk space by deleting unnecessary files",
251
- "Consider moving the BillClaw data directory to a drive with more space",
252
- ], undefined);
513
+ return createUserError(ERROR_CODES.STORAGE_DISK_FULL, ErrorCategory.STORAGE, "fatal", false, {
514
+ title: "Disk Full",
515
+ message: "No space left on device. Cannot save transactions.",
516
+ suggestions: [
517
+ "Free up disk space by deleting unnecessary files",
518
+ "Consider moving the BillClaw data directory to a drive with more space",
519
+ ],
520
+ }, [
521
+ {
522
+ type: "manual_intervention",
523
+ description: "Free up disk space manually",
524
+ },
525
+ ], {}, error);
253
526
  }
254
527
  // Directory not found
255
528
  if (code === "ENOENT" && message.includes("no such file")) {
256
- return createUserError(ErrorCategory.FILE_SYSTEM, "File or Directory Not Found", `The file or directory ${filePath || ""} does not exist.`, [
257
- "Run setup to initialize BillClaw",
258
- "Verify the data directory path is correct",
259
- ], undefined);
529
+ return createUserError(ERROR_CODES.FS_NOT_FOUND, ErrorCategory.FILE_SYSTEM, "error", false, {
530
+ title: "File or Directory Not Found",
531
+ message: `The file or directory ${filePath || ""} does not exist.`,
532
+ suggestions: [
533
+ "Run setup to initialize BillClaw",
534
+ "Verify the data directory path is correct",
535
+ ],
536
+ }, [
537
+ {
538
+ type: "config_change",
539
+ params: { setting: "data_directory" },
540
+ description: "Update data directory path",
541
+ },
542
+ ], { filePath }, error);
260
543
  }
261
544
  // Generic file system error
262
- return createUserError(ErrorCategory.FILE_SYSTEM, "File System Error", message || "An error occurred while accessing the file system", [
263
- "Check file/directory permissions",
264
- "Ensure the data directory exists and is writable",
265
- "Try running setup to reinitialize",
266
- ], undefined);
545
+ return createUserError(ERROR_CODES.FS_GENERIC, ErrorCategory.FILE_SYSTEM, "error", true, {
546
+ title: "File System Error",
547
+ message: message ||
548
+ "An error occurred while accessing the file system",
549
+ suggestions: [
550
+ "Check file/directory permissions",
551
+ "Ensure the data directory exists and is writable",
552
+ "Try running setup to reinitialize",
553
+ ],
554
+ }, [
555
+ {
556
+ type: "manual_intervention",
557
+ description: "Manual intervention may be required",
558
+ },
559
+ ], { filePath }, error);
267
560
  }
268
561
  /**
269
562
  * Type guard to check if error is a UserError
@@ -272,23 +565,29 @@ export function isUserError(error) {
272
565
  return (typeof error === "object" &&
273
566
  error !== null &&
274
567
  "type" in error &&
275
- error.type === "UserError");
568
+ error.type === "UserError" &&
569
+ "errorCode" in error &&
570
+ "humanReadable" in error);
276
571
  }
277
572
  /**
278
573
  * Log error with context for debugging
279
574
  */
280
575
  export function logError(logger, error, context) {
281
576
  const logData = {
282
- timestamp: new Date().toISOString(),
577
+ timestamp: isUserError(error) ? error.timestamp : new Date().toISOString(),
578
+ errorCode: isUserError(error) ? error.errorCode : undefined,
283
579
  category: isUserError(error) ? error.category : ErrorCategory.UNKNOWN,
284
- message: error.message,
580
+ severity: isUserError(error) ? error.severity : undefined,
581
+ recoverable: isUserError(error) ? error.recoverable : undefined,
582
+ message: isUserError(error) ? error.humanReadable.message : error.message,
583
+ entities: isUserError(error) ? error.entities : undefined,
285
584
  context,
286
585
  };
287
- if (isUserError(error) && error.error) {
586
+ if (isUserError(error) && error.originalError) {
288
587
  logData.originalError = {
289
- name: error.error.name,
290
- message: error.error.message,
291
- stack: error.error.stack,
588
+ name: error.originalError.name,
589
+ message: error.originalError.message,
590
+ stack: error.originalError.stack,
292
591
  };
293
592
  }
294
593
  logger?.error?.("BillClaw error:", JSON.stringify(logData, null, 2));