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.
This commit is contained in:
parent
a9b7ed0dda
commit
66cbee2c53
10 changed files with 763 additions and 32 deletions
27
bttask
27
bttask
|
|
@ -98,6 +98,7 @@ def init_db():
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
FOREIGN KEY (assigned_to) REFERENCES agents(id),
|
FOREIGN KEY (assigned_to) REFERENCES agents(id),
|
||||||
FOREIGN KEY (created_by) 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_tasks_assigned ON tasks(assigned_to);
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
|
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.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -414,11 +423,20 @@ def cmd_status(args):
|
||||||
return
|
return
|
||||||
|
|
||||||
old_status = task['status']
|
old_status = task['status']
|
||||||
db.execute(
|
current_version = task['version'] if task['version'] is not None else 1
|
||||||
"UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?",
|
|
||||||
(new_status, task['id'])
|
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
|
# Auto-add comment for status change
|
||||||
comment_id = str(uuid.uuid4())
|
comment_id = str(uuid.uuid4())
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -429,7 +447,8 @@ def cmd_status(args):
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
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):
|
def cmd_comment(args):
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,24 @@ fn open_db() -> Result<Connection, String> {
|
||||||
}
|
}
|
||||||
let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)
|
let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)
|
||||||
.map_err(|e| format!("Failed to open btmsg.db: {e}"))?;
|
.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}"))?;
|
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
|
||||||
conn.pragma_update(None, "busy_timeout", 5000)
|
conn.pragma_update(None, "busy_timeout", 5000)
|
||||||
.map_err(|e| format!("Failed to set busy_timeout: {e}"))?;
|
.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)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +67,7 @@ pub struct Task {
|
||||||
pub sort_order: i32,
|
pub sort_order: i32,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
pub version: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
@ -72,7 +87,7 @@ pub fn list_tasks(group_id: &str) -> Result<Vec<Task>, String> {
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, title, description, status, priority, assigned_to,
|
"SELECT id, title, description, status, priority, assigned_to,
|
||||||
created_by, group_id, parent_task_id, sort_order,
|
created_by, group_id, parent_task_id, sort_order,
|
||||||
created_at, updated_at
|
created_at, updated_at, version
|
||||||
FROM tasks WHERE group_id = ?1
|
FROM tasks WHERE group_id = ?1
|
||||||
ORDER BY sort_order ASC, created_at DESC",
|
ORDER BY sort_order ASC, created_at DESC",
|
||||||
)
|
)
|
||||||
|
|
@ -93,6 +108,7 @@ pub fn list_tasks(group_id: &str) -> Result<Vec<Task>, String> {
|
||||||
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
|
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
|
||||||
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
|
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
|
||||||
updated_at: row.get::<_, String>("updated_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}"))?;
|
.map_err(|e| format!("Query error: {e}"))?;
|
||||||
|
|
@ -128,9 +144,11 @@ pub fn task_comments(task_id: &str) -> Result<Vec<TaskComment>, String> {
|
||||||
.map_err(|e| format!("Row error: {e}"))
|
.map_err(|e| format!("Row error: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update task status
|
/// Update task status with optimistic locking.
|
||||||
/// When transitioning to 'review', auto-posts to #review-queue channel if it exists
|
/// `expected_version` must match the current version in the database.
|
||||||
pub fn update_task_status(task_id: &str, status: &str) -> Result<(), String> {
|
/// 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<i64, String> {
|
||||||
let valid = ["todo", "progress", "review", "done", "blocked"];
|
let valid = ["todo", "progress", "review", "done", "blocked"];
|
||||||
if !valid.contains(&status) {
|
if !valid.contains(&status) {
|
||||||
return Err(format!("Invalid status '{}'. Valid: {:?}", status, valid));
|
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
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
db.execute(
|
let rows_affected = db.execute(
|
||||||
"UPDATE tasks SET status = ?1, updated_at = datetime('now') WHERE id = ?2",
|
"UPDATE tasks SET status = ?1, version = version + 1, updated_at = datetime('now')
|
||||||
params![status, task_id],
|
WHERE id = ?2 AND version = ?3",
|
||||||
|
params![status, task_id, expected_version],
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Update error: {e}"))?;
|
.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
|
// Auto-post to #review-queue channel on review transition
|
||||||
if let Some((title, group_id)) = task_title {
|
if let Some((title, group_id)) = task_title {
|
||||||
notify_review_channel(&db, &group_id, task_id, &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)
|
/// Post a notification to #review-queue channel (best-effort, never fails the parent operation)
|
||||||
|
|
@ -295,7 +320,8 @@ mod tests {
|
||||||
parent_task_id TEXT,
|
parent_task_id TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
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 (
|
CREATE TABLE task_comments (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|
@ -342,7 +368,7 @@ mod tests {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, title, description, status, priority, assigned_to,
|
"SELECT id, title, description, status, priority, assigned_to,
|
||||||
created_by, group_id, parent_task_id, sort_order,
|
created_by, group_id, parent_task_id, sort_order,
|
||||||
created_at, updated_at
|
created_at, updated_at, version
|
||||||
FROM tasks WHERE group_id = ?1
|
FROM tasks WHERE group_id = ?1
|
||||||
ORDER BY sort_order ASC, created_at DESC",
|
ORDER BY sort_order ASC, created_at DESC",
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
@ -361,6 +387,7 @@ mod tests {
|
||||||
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
|
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
|
||||||
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
|
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
|
||||||
updated_at: row.get::<_, String>("updated_at").unwrap_or_default(),
|
updated_at: row.get::<_, String>("updated_at").unwrap_or_default(),
|
||||||
|
version: row.get::<_, i64>("version").unwrap_or(1),
|
||||||
})
|
})
|
||||||
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
|
||||||
|
|
||||||
|
|
@ -434,6 +461,7 @@ mod tests {
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
created_at: "2026-01-01".into(),
|
created_at: "2026-01-01".into(),
|
||||||
updated_at: "2026-01-01".into(),
|
updated_at: "2026-01-01".into(),
|
||||||
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_value(&task).unwrap();
|
let json = serde_json::to_value(&task).unwrap();
|
||||||
|
|
@ -606,4 +634,133 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count_g2, 1, "should count only review tasks in g2");
|
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<Task> = 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::<Result<Vec<_>, _>>().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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ pub fn bttask_comments(task_id: String) -> Result<Vec<bttask::TaskComment>, Stri
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn bttask_update_status(task_id: String, status: String) -> Result<(), String> {
|
pub fn bttask_update_status(task_id: String, status: String, version: i64) -> Result<i64, String> {
|
||||||
bttask::update_task_status(&task_id, &status)
|
bttask::update_task_status(&task_id, &status, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface Task {
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskComment {
|
export interface TaskComment {
|
||||||
|
|
@ -34,8 +35,9 @@ export async function getTaskComments(taskId: string): Promise<TaskComment[]> {
|
||||||
return invoke<TaskComment[]>('bttask_comments', { taskId });
|
return invoke<TaskComment[]>('bttask_comments', { taskId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTaskStatus(taskId: string, status: string): Promise<void> {
|
/** Update task status with optimistic locking. Returns the new version number. */
|
||||||
return invoke('bttask_update_status', { taskId, status });
|
export async function updateTaskStatus(taskId: string, status: string, version: number): Promise<number> {
|
||||||
|
return invoke<number>('bttask_update_status', { taskId, status, version });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
|
export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
import type { SessionAnchor } from '../../types/anchors';
|
import type { SessionAnchor } from '../../types/anchors';
|
||||||
|
|
||||||
import AgentTree from './AgentTree.svelte';
|
import AgentTree from './AgentTree.svelte';
|
||||||
|
import UsageMeter from './UsageMeter.svelte';
|
||||||
|
import { notify } from '../../stores/notifications.svelte';
|
||||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||||
import type {
|
import type {
|
||||||
TextContent,
|
TextContent,
|
||||||
|
|
@ -373,6 +375,35 @@
|
||||||
if (totalTokens === 0) return 0;
|
if (totalTokens === 0) return 0;
|
||||||
return Math.min(100, Math.round((totalTokens / DEFAULT_CONTEXT_LIMIT) * 100));
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="agent-pane" data-testid="agent-pane" data-agent-status={session?.status ?? 'idle'}>
|
<div class="agent-pane" data-testid="agent-pane" data-agent-status={session?.status ?? 'idle'}>
|
||||||
|
|
@ -538,12 +569,17 @@
|
||||||
<div class="running-indicator">
|
<div class="running-indicator">
|
||||||
<span class="pulse"></span>
|
<span class="pulse"></span>
|
||||||
<span>Running...</span>
|
<span>Running...</span>
|
||||||
{#if contextPercent > 0}
|
{#if capabilities.supportsCost && (session.inputTokens > 0 || session.outputTokens > 0)}
|
||||||
<span class="context-meter" title="Context window usage">
|
<UsageMeter inputTokens={session.inputTokens} outputTokens={session.outputTokens} contextLimit={DEFAULT_CONTEXT_LIMIT} />
|
||||||
<span class="context-fill" class:context-streaming={isRunning} style="width: {contextPercent}%"></span>
|
{:else if contextPercent > 0}
|
||||||
|
<span class="context-meter {contextColorClass}" title="Context window usage">
|
||||||
|
<span class="context-fill context-streaming" style="width: {contextPercent}%"></span>
|
||||||
<span class="context-label">{contextPercent}%</span>
|
<span class="context-label">{contextPercent}%</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if burnRatePerHr > 0}
|
||||||
|
<span class="burn-rate" title="Current session burn rate">${burnRatePerHr.toFixed(2)}/hr</span>
|
||||||
|
{/if}
|
||||||
{#if !autoScroll}
|
{#if !autoScroll}
|
||||||
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -555,14 +591,17 @@
|
||||||
{#if totalCost && totalCost.costUsd > session.costUsd}
|
{#if totalCost && totalCost.costUsd > session.costUsd}
|
||||||
<span class="total-cost">(total: ${totalCost.costUsd.toFixed(4)})</span>
|
<span class="total-cost">(total: ${totalCost.costUsd.toFixed(4)})</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="cost-detail">{session.inputTokens + session.outputTokens} tok</span>
|
{#if capabilities.supportsCost}
|
||||||
<span class="cost-detail">{(session.durationMs / 1000).toFixed(1)}s</span>
|
<span class="cost-detail token-in" title="Input tokens">{session.inputTokens.toLocaleString()} in</span>
|
||||||
{#if contextPercent > 0}
|
<span class="cost-detail token-out" title="Output tokens">{session.outputTokens.toLocaleString()} out</span>
|
||||||
<span class="context-meter" title="Context window usage">
|
{:else}
|
||||||
<span class="context-fill" style="width: {contextPercent}%"></span>
|
<span class="cost-detail">{session.inputTokens + session.outputTokens} tok</span>
|
||||||
<span class="context-label">{contextPercent}%</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<span class="cost-detail">{(session.durationMs / 1000).toFixed(1)}s</span>
|
||||||
|
{#if burnRatePerHr > 0}
|
||||||
|
<span class="burn-rate" title="Session average burn rate">${burnRatePerHr.toFixed(2)}/hr</span>
|
||||||
|
{/if}
|
||||||
|
<UsageMeter inputTokens={session.inputTokens} outputTokens={session.outputTokens} contextLimit={DEFAULT_CONTEXT_LIMIT} />
|
||||||
{#if !autoScroll}
|
{#if !autoScroll}
|
||||||
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
<button class="scroll-btn" onclick={() => { autoScroll = true; scrollContainer?.querySelector('#message-end')?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); }}>↓ Bottom</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -1333,6 +1372,21 @@
|
||||||
.total-cost { color: var(--ctp-overlay1); font-size: 0.6875rem; }
|
.total-cost { color: var(--ctp-overlay1); font-size: 0.6875rem; }
|
||||||
.error-bar { color: var(--ctp-red); }
|
.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 === */
|
||||||
.session-controls {
|
.session-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
146
v2/src/lib/components/Agent/UsageMeter.svelte
Normal file
146
v2/src/lib/components/Agent/UsageMeter.svelte
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
contextLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { inputTokens, outputTokens, contextLimit = 200_000 }: Props = $props();
|
||||||
|
|
||||||
|
let totalTokens = $derived(inputTokens + outputTokens);
|
||||||
|
let pct = $derived(contextLimit > 0 ? Math.min((totalTokens / contextLimit) * 100, 100) : 0);
|
||||||
|
|
||||||
|
let thresholdClass = $derived.by(() => {
|
||||||
|
if (pct >= 90) return 'critical';
|
||||||
|
if (pct >= 75) return 'high';
|
||||||
|
if (pct >= 50) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
let showTooltip = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalTokens > 0}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="usage-meter"
|
||||||
|
class:critical={thresholdClass === 'critical'}
|
||||||
|
class:high={thresholdClass === 'high'}
|
||||||
|
class:medium={thresholdClass === 'medium'}
|
||||||
|
class:low={thresholdClass === 'low'}
|
||||||
|
onmouseenter={() => showTooltip = true}
|
||||||
|
onmouseleave={() => showTooltip = false}
|
||||||
|
>
|
||||||
|
<div class="meter-track">
|
||||||
|
<div class="meter-fill" style="width: {pct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="meter-label">{formatTokens(totalTokens)}</span>
|
||||||
|
|
||||||
|
{#if showTooltip}
|
||||||
|
<div class="meter-tooltip">
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-key">Input</span>
|
||||||
|
<span class="tooltip-val">{formatTokens(inputTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-key">Output</span>
|
||||||
|
<span class="tooltip-val">{formatTokens(outputTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-key">Total</span>
|
||||||
|
<span class="tooltip-val">{formatTokens(totalTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-divider"></div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-key">Limit</span>
|
||||||
|
<span class="tooltip-val">{formatTokens(contextLimit)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-key">Used</span>
|
||||||
|
<span class="tooltip-val">{pct.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.usage-meter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-track {
|
||||||
|
width: 3rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
transition: width 0.3s ease, background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.low .meter-fill { background: var(--ctp-green); }
|
||||||
|
.medium .meter-fill { background: var(--ctp-yellow); }
|
||||||
|
.high .meter-fill { background: var(--ctp-peach); }
|
||||||
|
.critical .meter-fill { background: var(--ctp-red); }
|
||||||
|
|
||||||
|
.meter-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-family: var(--term-font-family, monospace);
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 0.375rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
min-width: 7.5rem;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 0.125rem 0.5rem color-mix(in srgb, var(--ctp-crust) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.0625rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-key {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-val {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-family: var(--term-font-family, monospace);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
margin: 0.1875rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -262,6 +262,31 @@
|
||||||
return `${m}m ${s % 60}s`;
|
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 {
|
function opColor(op: string): string {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case 'read': return 'var(--ctp-blue)';
|
case 'read': return 'var(--ctp-blue)';
|
||||||
|
|
@ -689,6 +714,39 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cost Analytics -->
|
||||||
|
{#if totalCost.costUsd > 0}
|
||||||
|
<div class="cost-analytics-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Cost Analytics</span>
|
||||||
|
</div>
|
||||||
|
<div class="cost-grid">
|
||||||
|
<div class="cost-cell">
|
||||||
|
<span class="cost-cell-value">{formatCost(totalCost.costUsd)}</span>
|
||||||
|
<span class="cost-cell-label">Total Cost</span>
|
||||||
|
</div>
|
||||||
|
<div class="cost-cell">
|
||||||
|
<span class="cost-cell-value">{formatCost(avgCostPerTurn)}</span>
|
||||||
|
<span class="cost-cell-label">Avg / Turn</span>
|
||||||
|
</div>
|
||||||
|
<div class="cost-cell">
|
||||||
|
<span class="cost-cell-value">{tokenEfficiency.toFixed(2)}</span>
|
||||||
|
<span class="cost-cell-label">Out/In Ratio</span>
|
||||||
|
</div>
|
||||||
|
<div class="cost-cell">
|
||||||
|
<span class="cost-cell-value">${burnRatePerHr.toFixed(2)}/hr</span>
|
||||||
|
<span class="cost-cell-label">Burn Rate</span>
|
||||||
|
</div>
|
||||||
|
{#if costProjection > 0}
|
||||||
|
<div class="cost-cell projection">
|
||||||
|
<span class="cost-cell-value">{formatCost(costProjection)}</span>
|
||||||
|
<span class="cost-cell-label">Est. Full Context</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Context Meter -->
|
<!-- Context Meter -->
|
||||||
<div class="meter-section">
|
<div class="meter-section">
|
||||||
<div class="meter-header">
|
<div class="meter-header">
|
||||||
|
|
@ -1139,6 +1197,52 @@
|
||||||
.compaction-pill .stat-value { color: var(--ctp-yellow); }
|
.compaction-pill .stat-value { color: var(--ctp-yellow); }
|
||||||
.compaction-pill .stat-label { color: var(--ctp-yellow); opacity: 0.7; }
|
.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 */
|
/* Context meter */
|
||||||
.meter-section {
|
.meter-section {
|
||||||
padding: 0.5rem 0.625rem;
|
padding: 0.5rem 0.625rem;
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,18 @@
|
||||||
|
|
||||||
async function handleStatusChange(taskId: string, newStatus: string) {
|
async function handleStatusChange(taskId: string, newStatus: string) {
|
||||||
try {
|
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();
|
await loadTasks();
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.warn('Failed to update task status:', e);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
120
v2/src/lib/utils/error-classifier.test.ts
Normal file
120
v2/src/lib/utils/error-classifier.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
121
v2/src/lib/utils/error-classifier.ts
Normal file
121
v2/src/lib/utils/error-classifier.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue