From 66cbee2c53dd61d039f9c2b68ab89fd7148d33e3 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 12 Mar 2026 04:57:29 +0100 Subject: [PATCH] feat: add optimistic locking for bttask and error classification Version column in tasks table with WHERE id=? AND version=? guard. Conflict detection in TaskBoardTab. error-classifier.ts: 6 error types with actionable messages and retry logic. UsageMeter.svelte. --- bttask | 27 ++- v2/src-tauri/src/bttask.rs | 179 ++++++++++++++++-- v2/src-tauri/src/commands/bttask.rs | 4 +- v2/src/lib/adapters/bttask-bridge.ts | 6 +- v2/src/lib/components/Agent/AgentPane.svelte | 74 +++++++- v2/src/lib/components/Agent/UsageMeter.svelte | 146 ++++++++++++++ .../components/Workspace/ContextTab.svelte | 104 ++++++++++ .../components/Workspace/TaskBoardTab.svelte | 14 +- v2/src/lib/utils/error-classifier.test.ts | 120 ++++++++++++ v2/src/lib/utils/error-classifier.ts | 121 ++++++++++++ 10 files changed, 763 insertions(+), 32 deletions(-) create mode 100644 v2/src/lib/components/Agent/UsageMeter.svelte create mode 100644 v2/src/lib/utils/error-classifier.test.ts create mode 100644 v2/src/lib/utils/error-classifier.ts diff --git a/bttask b/bttask index 0478739..6f642f3 100755 --- a/bttask +++ b/bttask @@ -98,6 +98,7 @@ def init_db(): sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), + version INTEGER DEFAULT 1, FOREIGN KEY (assigned_to) REFERENCES agents(id), FOREIGN KEY (created_by) REFERENCES agents(id) ); @@ -117,6 +118,14 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to); CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id); """) + + # Migration: add version column if missing (for existing databases) + cursor = db.execute("PRAGMA table_info(tasks)") + columns = [row[1] for row in cursor.fetchall()] + if 'version' not in columns: + db.execute("ALTER TABLE tasks ADD COLUMN version INTEGER DEFAULT 1") + db.commit() + db.commit() db.close() @@ -414,11 +423,20 @@ def cmd_status(args): return old_status = task['status'] - db.execute( - "UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?", - (new_status, task['id']) + current_version = task['version'] if task['version'] is not None else 1 + + cursor = db.execute( + "UPDATE tasks SET status = ?, version = version + 1, updated_at = datetime('now') " + "WHERE id = ? AND version = ?", + (new_status, task['id'], current_version) ) + if cursor.rowcount == 0: + print(f"{C_RED}Error: Task was modified by another agent (version conflict).{C_RESET}") + print(f"{C_DIM}Re-fetch the task and try again.{C_RESET}") + db.close() + sys.exit(1) + # Auto-add comment for status change comment_id = str(uuid.uuid4()) db.execute( @@ -429,7 +447,8 @@ def cmd_status(args): db.commit() db.close() - print(f"{C_GREEN}✓ [{short_id(task['id'])}] {format_state(old_status)} → {format_state(new_status)}{C_RESET}") + new_version = current_version + 1 + print(f"{C_GREEN}✓ [{short_id(task['id'])}] {format_state(old_status)} → {format_state(new_status)} {C_DIM}(v{new_version}){C_RESET}") def cmd_comment(args): diff --git a/v2/src-tauri/src/bttask.rs b/v2/src-tauri/src/bttask.rs index 2ea642a..6c46cfe 100644 --- a/v2/src-tauri/src/bttask.rs +++ b/v2/src-tauri/src/bttask.rs @@ -31,10 +31,24 @@ fn open_db() -> Result { } let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE) .map_err(|e| format!("Failed to open btmsg.db: {e}"))?; - conn.pragma_update(None, "journal_mode", "WAL") + conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(())) .map_err(|e| format!("Failed to set WAL mode: {e}"))?; conn.pragma_update(None, "busy_timeout", 5000) .map_err(|e| format!("Failed to set busy_timeout: {e}"))?; + + // Migration: add version column if missing + let has_version: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='version'", + [], + |row| row.get(0), + ) + .unwrap_or(0); + if has_version == 0 { + conn.execute("ALTER TABLE tasks ADD COLUMN version INTEGER DEFAULT 1", []) + .map_err(|e| format!("Migration (version column) failed: {e}"))?; + } + Ok(conn) } @@ -53,6 +67,7 @@ pub struct Task { pub sort_order: i32, pub created_at: String, pub updated_at: String, + pub version: i64, } #[derive(Debug, Serialize, Deserialize)] @@ -72,7 +87,7 @@ pub fn list_tasks(group_id: &str) -> Result, String> { .prepare( "SELECT id, title, description, status, priority, assigned_to, created_by, group_id, parent_task_id, sort_order, - created_at, updated_at + created_at, updated_at, version FROM tasks WHERE group_id = ?1 ORDER BY sort_order ASC, created_at DESC", ) @@ -93,6 +108,7 @@ pub fn list_tasks(group_id: &str) -> Result, String> { sort_order: row.get::<_, i32>("sort_order").unwrap_or(0), created_at: row.get::<_, String>("created_at").unwrap_or_default(), updated_at: row.get::<_, String>("updated_at").unwrap_or_default(), + version: row.get::<_, i64>("version").unwrap_or(1), }) }) .map_err(|e| format!("Query error: {e}"))?; @@ -128,9 +144,11 @@ pub fn task_comments(task_id: &str) -> Result, String> { .map_err(|e| format!("Row error: {e}")) } -/// Update task status -/// When transitioning to 'review', auto-posts to #review-queue channel if it exists -pub fn update_task_status(task_id: &str, status: &str) -> Result<(), String> { +/// Update task status with optimistic locking. +/// `expected_version` must match the current version in the database. +/// Returns the new version on success. +/// When transitioning to 'review', auto-posts to #review-queue channel if it exists. +pub fn update_task_status(task_id: &str, status: &str, expected_version: i64) -> Result { let valid = ["todo", "progress", "review", "done", "blocked"]; if !valid.contains(&status) { return Err(format!("Invalid status '{}'. Valid: {:?}", status, valid)); @@ -148,18 +166,25 @@ pub fn update_task_status(task_id: &str, status: &str) -> Result<(), String> { None }; - db.execute( - "UPDATE tasks SET status = ?1, updated_at = datetime('now') WHERE id = ?2", - params![status, task_id], + let rows_affected = db.execute( + "UPDATE tasks SET status = ?1, version = version + 1, updated_at = datetime('now') + WHERE id = ?2 AND version = ?3", + params![status, task_id, expected_version], ) .map_err(|e| format!("Update error: {e}"))?; + if rows_affected == 0 { + return Err("Task was modified by another agent (version conflict)".into()); + } + + let new_version = expected_version + 1; + // Auto-post to #review-queue channel on review transition if let Some((title, group_id)) = task_title { notify_review_channel(&db, &group_id, task_id, &title); } - Ok(()) + Ok(new_version) } /// Post a notification to #review-queue channel (best-effort, never fails the parent operation) @@ -295,7 +320,8 @@ mod tests { parent_task_id TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) + updated_at TEXT DEFAULT (datetime('now')), + version INTEGER DEFAULT 1 ); CREATE TABLE task_comments ( id TEXT PRIMARY KEY, @@ -342,7 +368,7 @@ mod tests { let mut stmt = conn.prepare( "SELECT id, title, description, status, priority, assigned_to, created_by, group_id, parent_task_id, sort_order, - created_at, updated_at + created_at, updated_at, version FROM tasks WHERE group_id = ?1 ORDER BY sort_order ASC, created_at DESC", ).unwrap(); @@ -361,6 +387,7 @@ mod tests { sort_order: row.get::<_, i32>("sort_order").unwrap_or(0), created_at: row.get::<_, String>("created_at").unwrap_or_default(), updated_at: row.get::<_, String>("updated_at").unwrap_or_default(), + version: row.get::<_, i64>("version").unwrap_or(1), }) }).unwrap().collect::, _>>().unwrap(); @@ -434,6 +461,7 @@ mod tests { sort_order: 0, created_at: "2026-01-01".into(), updated_at: "2026-01-01".into(), + version: 1, }; let json = serde_json::to_value(&task).unwrap(); @@ -606,4 +634,133 @@ mod tests { .unwrap(); assert_eq!(count_g2, 1, "should count only review tasks in g2"); } + + // ---- Optimistic locking (version column) ---- + + #[test] + fn test_version_column_defaults_to_1() { + let conn = test_db(); + conn.execute( + "INSERT INTO tasks (id, title, created_by, group_id) VALUES ('t1', 'Test', 'admin', 'g1')", + [], + ).unwrap(); + + let version: i64 = conn + .query_row("SELECT version FROM tasks WHERE id = 't1'", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 1); + } + + #[test] + fn test_optimistic_lock_success() { + let conn = test_db(); + conn.execute( + "INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t1', 'Test', 'todo', 'admin', 'g1')", + [], + ).unwrap(); + + // Update with correct version (1) + let rows = conn.execute( + "UPDATE tasks SET status = 'progress', version = version + 1, updated_at = datetime('now') + WHERE id = 't1' AND version = 1", + [], + ).unwrap(); + assert_eq!(rows, 1, "should affect 1 row"); + + let new_version: i64 = conn + .query_row("SELECT version FROM tasks WHERE id = 't1'", [], |row| row.get(0)) + .unwrap(); + assert_eq!(new_version, 2); + } + + #[test] + fn test_optimistic_lock_conflict() { + let conn = test_db(); + conn.execute( + "INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t1', 'Test', 'todo', 'admin', 'g1')", + [], + ).unwrap(); + + // First update succeeds + conn.execute( + "UPDATE tasks SET status = 'progress', version = version + 1, updated_at = datetime('now') + WHERE id = 't1' AND version = 1", + [], + ).unwrap(); + + // Second update with stale version (1) should affect 0 rows + let rows = conn.execute( + "UPDATE tasks SET status = 'review', version = version + 1, updated_at = datetime('now') + WHERE id = 't1' AND version = 1", + [], + ).unwrap(); + assert_eq!(rows, 0, "stale version should affect 0 rows"); + + // Task should still be in 'progress' state + let status: String = conn + .query_row("SELECT status FROM tasks WHERE id = 't1'", [], |row| row.get(0)) + .unwrap(); + assert_eq!(status, "progress"); + } + + #[test] + fn test_version_in_list_tasks_query() { + let conn = test_db(); + conn.execute( + "INSERT INTO tasks (id, title, created_by, group_id, sort_order) VALUES ('t1', 'V1', 'admin', 'g1', 1)", + [], + ).unwrap(); + // Bump version to 3 + conn.execute("UPDATE tasks SET version = 3 WHERE id = 't1'", []).unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, title, description, status, priority, assigned_to, + created_by, group_id, parent_task_id, sort_order, + created_at, updated_at, version + FROM tasks WHERE group_id = ?1", + ).unwrap(); + + let tasks: Vec = stmt.query_map(params!["g1"], |row| { + Ok(Task { + id: row.get("id")?, + title: row.get("title")?, + description: row.get::<_, String>("description").unwrap_or_default(), + status: row.get::<_, String>("status").unwrap_or_else(|_| "todo".into()), + priority: row.get::<_, String>("priority").unwrap_or_else(|_| "medium".into()), + assigned_to: row.get("assigned_to")?, + created_by: row.get("created_by")?, + group_id: row.get("group_id")?, + parent_task_id: row.get("parent_task_id")?, + sort_order: row.get::<_, i32>("sort_order").unwrap_or(0), + created_at: row.get::<_, String>("created_at").unwrap_or_default(), + updated_at: row.get::<_, String>("updated_at").unwrap_or_default(), + version: row.get::<_, i64>("version").unwrap_or(1), + }) + }).unwrap().collect::, _>>().unwrap(); + + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].version, 3); + } + + #[test] + fn test_version_serializes_to_camel_case() { + let task = Task { + id: "t1".into(), + title: "Test".into(), + description: "".into(), + status: "todo".into(), + priority: "medium".into(), + assigned_to: None, + created_by: "admin".into(), + group_id: "g1".into(), + parent_task_id: None, + sort_order: 0, + created_at: "2026-01-01".into(), + updated_at: "2026-01-01".into(), + version: 5, + }; + + let json = serde_json::to_value(&task).unwrap(); + assert_eq!(json.get("version").unwrap(), 5); + } } diff --git a/v2/src-tauri/src/commands/bttask.rs b/v2/src-tauri/src/commands/bttask.rs index 663744a..b64b574 100644 --- a/v2/src-tauri/src/commands/bttask.rs +++ b/v2/src-tauri/src/commands/bttask.rs @@ -11,8 +11,8 @@ pub fn bttask_comments(task_id: String) -> Result, Stri } #[tauri::command] -pub fn bttask_update_status(task_id: String, status: String) -> Result<(), String> { - bttask::update_task_status(&task_id, &status) +pub fn bttask_update_status(task_id: String, status: String, version: i64) -> Result { + bttask::update_task_status(&task_id, &status, version) } #[tauri::command] diff --git a/v2/src/lib/adapters/bttask-bridge.ts b/v2/src/lib/adapters/bttask-bridge.ts index e195e84..adfecea 100644 --- a/v2/src/lib/adapters/bttask-bridge.ts +++ b/v2/src/lib/adapters/bttask-bridge.ts @@ -16,6 +16,7 @@ export interface Task { sortOrder: number; createdAt: string; updatedAt: string; + version: number; } export interface TaskComment { @@ -34,8 +35,9 @@ export async function getTaskComments(taskId: string): Promise { return invoke('bttask_comments', { taskId }); } -export async function updateTaskStatus(taskId: string, status: string): Promise { - return invoke('bttask_update_status', { taskId, status }); +/** Update task status with optimistic locking. Returns the new version number. */ +export async function updateTaskStatus(taskId: string, status: string, version: number): Promise { + return invoke('bttask_update_status', { taskId, status, version }); } export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise { diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index b2a8384..6031fbc 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -16,6 +16,8 @@ import type { SessionAnchor } from '../../types/anchors'; import AgentTree from './AgentTree.svelte'; + import UsageMeter from './UsageMeter.svelte'; + import { notify } from '../../stores/notifications.svelte'; import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight'; import type { TextContent, @@ -373,6 +375,35 @@ if (totalTokens === 0) return 0; return Math.min(100, Math.round((totalTokens / DEFAULT_CONTEXT_LIMIT) * 100)); }); + + // Context meter color class based on thresholds + let contextColorClass = $derived.by(() => { + if (contextPercent >= 90) return 'context-critical'; + if (contextPercent >= 75) return 'context-high'; + if (contextPercent >= 50) return 'context-medium'; + return ''; + }); + + // Session burn rate ($/hr) + let burnRatePerHr = $derived.by(() => { + if (!session || session.durationMs <= 0 || session.costUsd <= 0) return 0; + return (session.costUsd / session.durationMs) * 3_600_000; + }); + + // 90% context warning (fire once per session) + let contextWarningFired = $state(false); + $effect(() => { + if (contextPercent >= 90 && !contextWarningFired && session?.status === 'running') { + contextWarningFired = true; + notify('warning', `Context usage at ${contextPercent}% — approaching model limit`); + } + }); + // Reset warning tracker when session changes + $effect(() => { + if (session?.id) { + contextWarningFired = false; + } + });
@@ -538,12 +569,17 @@
Running... - {#if contextPercent > 0} - - + {#if capabilities.supportsCost && (session.inputTokens > 0 || session.outputTokens > 0)} + + {:else if contextPercent > 0} + + {contextPercent}% {/if} + {#if burnRatePerHr > 0} + ${burnRatePerHr.toFixed(2)}/hr + {/if} {#if !autoScroll} {/if} @@ -555,14 +591,17 @@ {#if totalCost && totalCost.costUsd > session.costUsd} (total: ${totalCost.costUsd.toFixed(4)}) {/if} - {session.inputTokens + session.outputTokens} tok - {(session.durationMs / 1000).toFixed(1)}s - {#if contextPercent > 0} - - - {contextPercent}% - + {#if capabilities.supportsCost} + {session.inputTokens.toLocaleString()} in + {session.outputTokens.toLocaleString()} out + {:else} + {session.inputTokens + session.outputTokens} tok {/if} + {(session.durationMs / 1000).toFixed(1)}s + {#if burnRatePerHr > 0} + ${burnRatePerHr.toFixed(2)}/hr + {/if} + {#if !autoScroll} {/if} @@ -1333,6 +1372,21 @@ .total-cost { color: var(--ctp-overlay1); font-size: 0.6875rem; } .error-bar { color: var(--ctp-red); } + .burn-rate { + font-size: 0.625rem; + font-family: var(--term-font-family, monospace); + color: var(--ctp-peach); + white-space: nowrap; + } + + .token-in { color: var(--ctp-blue); } + .token-out { color: var(--ctp-green); } + + /* Context meter threshold colors */ + .context-medium .context-fill { background: var(--ctp-yellow); } + .context-high .context-fill { background: var(--ctp-peach); } + .context-critical .context-fill { background: var(--ctp-red); } + /* === Session controls === */ .session-controls { display: flex; diff --git a/v2/src/lib/components/Agent/UsageMeter.svelte b/v2/src/lib/components/Agent/UsageMeter.svelte new file mode 100644 index 0000000..86c8b9a --- /dev/null +++ b/v2/src/lib/components/Agent/UsageMeter.svelte @@ -0,0 +1,146 @@ + + +{#if totalTokens > 0} + +
showTooltip = true} + onmouseleave={() => showTooltip = false} + > +
+
+
+ {formatTokens(totalTokens)} + + {#if showTooltip} +
+
+ Input + {formatTokens(inputTokens)} +
+
+ Output + {formatTokens(outputTokens)} +
+
+ Total + {formatTokens(totalTokens)} +
+
+
+ Limit + {formatTokens(contextLimit)} +
+
+ Used + {pct.toFixed(1)}% +
+
+ {/if} +
+{/if} + + diff --git a/v2/src/lib/components/Workspace/ContextTab.svelte b/v2/src/lib/components/Workspace/ContextTab.svelte index 3f48de6..c69dd4a 100644 --- a/v2/src/lib/components/Workspace/ContextTab.svelte +++ b/v2/src/lib/components/Workspace/ContextTab.svelte @@ -262,6 +262,31 @@ return `${m}m ${s % 60}s`; } + // --- Cost analytics --- + let avgCostPerTurn = $derived( + session && session.numTurns > 0 + ? totalCost.costUsd / session.numTurns + : 0 + ); + + let tokenEfficiency = $derived( + totalCost.inputTokens > 0 + ? totalCost.outputTokens / totalCost.inputTokens + : 0 + ); + + let burnRatePerHr = $derived.by(() => { + if (!session || session.durationMs <= 0 || totalCost.costUsd <= 0) return 0; + return (totalCost.costUsd / session.durationMs) * 3_600_000; + }); + + // Cost projection: estimate total cost if context fills to 100% + let costProjection = $derived.by(() => { + const usedPct = totalCost.inputTokens / CONTEXT_WINDOW; + if (usedPct <= 0) return 0; + return totalCost.costUsd / usedPct; + }); + function opColor(op: string): string { switch (op) { case 'read': return 'var(--ctp-blue)'; @@ -689,6 +714,39 @@ {/if}
+ + {#if totalCost.costUsd > 0} +
+
+ Cost Analytics +
+
+
+ {formatCost(totalCost.costUsd)} + Total Cost +
+
+ {formatCost(avgCostPerTurn)} + Avg / Turn +
+
+ {tokenEfficiency.toFixed(2)} + Out/In Ratio +
+
+ ${burnRatePerHr.toFixed(2)}/hr + Burn Rate +
+ {#if costProjection > 0} +
+ {formatCost(costProjection)} + Est. Full Context +
+ {/if} +
+
+ {/if} +
@@ -1139,6 +1197,52 @@ .compaction-pill .stat-value { color: var(--ctp-yellow); } .compaction-pill .stat-label { color: var(--ctp-yellow); opacity: 0.7; } + /* Cost Analytics */ + .cost-analytics-section { + padding: 0.375rem 0.625rem; + border-bottom: 1px solid var(--ctp-surface0); + flex-shrink: 0; + } + + .cost-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr)); + gap: 0.375rem; + margin-top: 0.25rem; + } + + .cost-cell { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.375rem 0.25rem; + background: var(--ctp-surface0); + border-radius: 0.25rem; + } + + .cost-cell-value { + font-size: 0.75rem; + font-weight: 700; + font-family: var(--term-font-family, monospace); + color: var(--ctp-text); + } + + .cost-cell-label { + font-size: 0.55rem; + color: var(--ctp-overlay0); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 0.125rem; + } + + .cost-cell.projection { + background: color-mix(in srgb, var(--ctp-peach) 10%, var(--ctp-surface0)); + } + + .cost-cell.projection .cost-cell-value { + color: var(--ctp-peach); + } + /* Context meter */ .meter-section { padding: 0.5rem 0.625rem; diff --git a/v2/src/lib/components/Workspace/TaskBoardTab.svelte b/v2/src/lib/components/Workspace/TaskBoardTab.svelte index e16e617..4042bdc 100644 --- a/v2/src/lib/components/Workspace/TaskBoardTab.svelte +++ b/v2/src/lib/components/Workspace/TaskBoardTab.svelte @@ -84,10 +84,18 @@ async function handleStatusChange(taskId: string, newStatus: string) { try { - await updateTaskStatus(taskId, newStatus); + const task = tasks.find(t => t.id === taskId); + const version = task?.version ?? 1; + await updateTaskStatus(taskId, newStatus, version); await loadTasks(); - } catch (e) { - console.warn('Failed to update task status:', e); + } catch (e: any) { + const msg = e?.message ?? String(e); + if (msg.includes('version conflict')) { + console.warn('Version conflict on task update, reloading:', msg); + await loadTasks(); + } else { + console.warn('Failed to update task status:', e); + } } } diff --git a/v2/src/lib/utils/error-classifier.test.ts b/v2/src/lib/utils/error-classifier.test.ts new file mode 100644 index 0000000..5e79e0e --- /dev/null +++ b/v2/src/lib/utils/error-classifier.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { classifyError, type ApiErrorType } from './error-classifier'; + +describe('classifyError', () => { + // --- Rate limit --- + it('classifies "rate_limit_error" as rate_limit', () => { + const result = classifyError('rate_limit_error: Too many requests'); + expect(result.type).toBe('rate_limit'); + expect(result.retryable).toBe(true); + expect(result.retryDelaySec).toBeGreaterThan(0); + }); + + it('classifies "429" as rate_limit', () => { + const result = classifyError('HTTP 429 Too Many Requests'); + expect(result.type).toBe('rate_limit'); + }); + + it('classifies "too many requests" as rate_limit', () => { + const result = classifyError('Error: too many requests, please slow down'); + expect(result.type).toBe('rate_limit'); + }); + + it('classifies "throttled" as rate_limit', () => { + const result = classifyError('Request throttled by API'); + expect(result.type).toBe('rate_limit'); + }); + + // --- Auth --- + it('classifies "invalid_api_key" as auth', () => { + const result = classifyError('invalid_api_key: The provided API key is invalid'); + expect(result.type).toBe('auth'); + expect(result.retryable).toBe(false); + }); + + it('classifies "401" as auth', () => { + const result = classifyError('HTTP 401 Unauthorized'); + expect(result.type).toBe('auth'); + }); + + it('classifies "authentication failed" as auth', () => { + const result = classifyError('Authentication failed for this request'); + expect(result.type).toBe('auth'); + }); + + // --- Quota --- + it('classifies "insufficient_quota" as quota', () => { + const result = classifyError('insufficient_quota: You have exceeded your usage limit'); + expect(result.type).toBe('quota'); + expect(result.retryable).toBe(false); + }); + + it('classifies "billing" as quota', () => { + const result = classifyError('Error: billing issue with your account'); + expect(result.type).toBe('quota'); + }); + + it('classifies "credit" as quota', () => { + const result = classifyError('No remaining credit on your account'); + expect(result.type).toBe('quota'); + }); + + // --- Overloaded --- + it('classifies "overloaded" as overloaded', () => { + const result = classifyError('The API is temporarily overloaded'); + expect(result.type).toBe('overloaded'); + expect(result.retryable).toBe(true); + }); + + it('classifies "503" as overloaded', () => { + const result = classifyError('HTTP 503 Service Unavailable'); + expect(result.type).toBe('overloaded'); + }); + + // --- Network --- + it('classifies "ECONNREFUSED" as network', () => { + const result = classifyError('connect ECONNREFUSED 127.0.0.1:443'); + expect(result.type).toBe('network'); + expect(result.retryable).toBe(true); + }); + + it('classifies "ETIMEDOUT" as network', () => { + const result = classifyError('connect ETIMEDOUT'); + expect(result.type).toBe('network'); + }); + + it('classifies "fetch failed" as network', () => { + const result = classifyError('TypeError: fetch failed'); + expect(result.type).toBe('network'); + }); + + // --- Unknown --- + it('classifies unrecognized errors as unknown', () => { + const result = classifyError('Something weird happened'); + expect(result.type).toBe('unknown'); + expect(result.retryable).toBe(false); + expect(result.message).toBe('Something weird happened'); + }); + + it('preserves original message for unknown errors', () => { + const msg = 'Internal server error: null pointer'; + const result = classifyError(msg); + expect(result.message).toBe(msg); + }); + + // --- Message quality --- + it('provides actionable messages for rate_limit', () => { + const result = classifyError('rate_limit_error'); + expect(result.message).toContain('Rate limited'); + }); + + it('provides actionable messages for auth', () => { + const result = classifyError('invalid_api_key'); + expect(result.message).toContain('Settings'); + }); + + it('provides actionable messages for quota', () => { + const result = classifyError('insufficient_quota'); + expect(result.message).toContain('billing'); + }); +}); diff --git a/v2/src/lib/utils/error-classifier.ts b/v2/src/lib/utils/error-classifier.ts new file mode 100644 index 0000000..f8cc48f --- /dev/null +++ b/v2/src/lib/utils/error-classifier.ts @@ -0,0 +1,121 @@ +// Error classifier — categorizes API errors for actionable user messaging + +export type ApiErrorType = + | 'rate_limit' + | 'auth' + | 'quota' + | 'overloaded' + | 'network' + | 'unknown'; + +export interface ClassifiedError { + type: ApiErrorType; + message: string; + retryable: boolean; + /** Suggested retry delay in seconds (0 = no retry) */ + retryDelaySec: number; +} + +const RATE_LIMIT_PATTERNS = [ + /rate.?limit/i, + /429/, + /too many requests/i, + /rate_limit_error/i, + /throttl/i, +]; + +const AUTH_PATTERNS = [ + /401/, + /invalid.?api.?key/i, + /authentication/i, + /unauthorized/i, + /invalid.?x-api-key/i, + /api_key/i, +]; + +const QUOTA_PATTERNS = [ + /insufficient.?quota/i, + /billing/i, + /payment/i, + /exceeded.*quota/i, + /credit/i, + /usage.?limit/i, +]; + +const OVERLOADED_PATTERNS = [ + /overloaded/i, + /503/, + /service.?unavailable/i, + /capacity/i, + /busy/i, +]; + +const NETWORK_PATTERNS = [ + /ECONNREFUSED/, + /ECONNRESET/, + /ETIMEDOUT/, + /network/i, + /fetch.?failed/i, + /dns/i, +]; + +function matchesAny(text: string, patterns: RegExp[]): boolean { + return patterns.some(p => p.test(text)); +} + +/** + * Classify an error message into an actionable category. + */ +export function classifyError(errorMessage: string): ClassifiedError { + if (matchesAny(errorMessage, RATE_LIMIT_PATTERNS)) { + return { + type: 'rate_limit', + message: 'Rate limited. The API will auto-retry shortly.', + retryable: true, + retryDelaySec: 30, + }; + } + + if (matchesAny(errorMessage, AUTH_PATTERNS)) { + return { + type: 'auth', + message: 'API key invalid or expired. Check Settings.', + retryable: false, + retryDelaySec: 0, + }; + } + + if (matchesAny(errorMessage, QUOTA_PATTERNS)) { + return { + type: 'quota', + message: 'API quota exceeded. Check your billing.', + retryable: false, + retryDelaySec: 0, + }; + } + + if (matchesAny(errorMessage, OVERLOADED_PATTERNS)) { + return { + type: 'overloaded', + message: 'API overloaded. Retrying shortly...', + retryable: true, + retryDelaySec: 15, + }; + } + + if (matchesAny(errorMessage, NETWORK_PATTERNS)) { + return { + type: 'network', + message: 'Network error. Check your connection.', + retryable: true, + retryDelaySec: 5, + }; + } + + return { + type: 'unknown', + message: errorMessage, + retryable: false, + retryDelaySec: 0, + }; +}