feat(KB-618): add multi-project support to dashboard

- Add project API methods and types (fetchProjects, registerProject, fetchProjectHealth, etc.)
- Add ProjectCard component with health metrics, status badges, and pause/resume actions
- Add server-side project management routes for multi-project orchestration
- Add ActivityFeed component with grouped entries and project badges
- Add SetupWizard component with 5-step project creation flow
- Fix TypeScript errors in server routes for listProjects and getGlobalConcurrencyState
This commit is contained in:
gsxdsm
2026-03-31 23:19:03 -07:00
parent 38f7cec604
commit 7344a80e40
52 changed files with 498 additions and 1643 deletions

View File

@@ -25,6 +25,8 @@ import type {
FeatureCreateInput,
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState,
} from "@fusion/core";
import {
@@ -35,6 +37,14 @@ import {
INTERVIEW_STATES,
} from "@fusion/core";
// ── Param Utilities ────────────────────────────────────────────────────────
/** Extract a route param as string (Express 5 params can be string | string[]) */
function param(req: Request, name: string): string {
const val = req.params[name];
return Array.isArray(val) ? val[0] : val;
}
// ── Validation Utilities ────────────────────────────────────────────────────
function validateUuid(id: string): boolean {
@@ -174,7 +184,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -198,7 +208,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/:missionId",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
const { title, description, status } = req.body;
if (!validateMissionId(missionId)) {
@@ -243,7 +253,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/:missionId",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -268,7 +278,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/status",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -295,7 +305,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/interview-state",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -319,7 +329,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/interview-state",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
const { state } = req.body;
if (!validateMissionId(missionId)) {
@@ -351,7 +361,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/milestones",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -378,7 +388,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/milestones",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
const { title, description, dependencies } = req.body;
if (!validateMissionId(missionId)) {
@@ -414,7 +424,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/milestones/reorder",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const missionId = param(req, "missionId");
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -456,7 +466,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -480,7 +490,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
const { title, description, status, dependencies } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -528,7 +538,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -555,7 +565,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId/interview-state",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -579,7 +589,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/interview-state",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
const { state } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -611,7 +621,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId/slices",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -638,7 +648,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/slices",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
const { title, description } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -672,7 +682,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/slices/reorder",
asyncHandler(async (req, res) => {
const { milestoneId } = req.params;
const milestoneId = param(req, "milestoneId");
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -714,7 +724,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
const sliceId = param(req, "sliceId");
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -738,7 +748,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
const sliceId = param(req, "sliceId");
const { title, description, status } = req.body;
if (!validateSliceId(sliceId)) {
@@ -783,7 +793,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
const sliceId = param(req, "sliceId");
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -808,7 +818,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/slices/:sliceId/activate",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
const sliceId = param(req, "sliceId");
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -837,7 +847,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/slices/:sliceId/features",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
const sliceId = param(req, "sliceId");
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -862,7 +872,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/slices/:sliceId/features",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
const sliceId = param(req, "sliceId");
const { title, description, acceptanceCriteria } = req.body;
if (!validateSliceId(sliceId)) {
@@ -898,7 +908,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/features/:featureId",
asyncHandler(async (req, res) => {
const { featureId } = req.params;
const featureId = param(req, "featureId");
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
@@ -922,7 +932,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/features/:featureId",
asyncHandler(async (req, res) => {
const { featureId } = req.params;
const featureId = param(req, "featureId");
const { title, description, acceptanceCriteria, status } = req.body;
if (!validateFeatureId(featureId)) {
@@ -970,7 +980,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/features/:featureId",
asyncHandler(async (req, res) => {
const { featureId } = req.params;
const featureId = param(req, "featureId");
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
@@ -995,7 +1005,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/features/:featureId/link-task",
asyncHandler(async (req, res) => {
const { featureId } = req.params;
const featureId = param(req, "featureId");
const { taskId } = req.body;
if (!validateFeatureId(featureId)) {
@@ -1034,7 +1044,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/features/:featureId/unlink-task",
asyncHandler(async (req, res) => {
const { featureId } = req.params;
const featureId = param(req, "featureId");
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });

View File

@@ -33,18 +33,6 @@ function createMockGlobalSettingsStore() {
};
}
function createMockMissionStore() {
return {
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
updateSession: vi.fn().mockResolvedValue(undefined),
addAnswer: vi.fn().mockResolvedValue(undefined),
deleteSession: vi.fn().mockResolvedValue(undefined),
listSessions: vi.fn().mockResolvedValue([]),
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
@@ -75,7 +63,6 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
...overrides,
} as unknown as TaskStore;
}
@@ -3678,10 +3665,7 @@ describe("Git Management endpoints", () => {
let store: TaskStore;
beforeEach(() => {
// Use the actual project root so git commands work
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(process.cwd()),
});
store = createMockStore();
});
function buildApp() {

View File

@@ -1841,82 +1841,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/tasks/:id/diff
* Get detailed diff information for files modified during task execution.
* Returns: { files: string[]; diffs: Record<string, { stat: string; patch: string }> }
*/
router.get("/tasks/:id/diff", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
// Only tasks with worktrees can have diffs
if (!task.worktree || !existsSync(task.worktree)) {
res.json({ files: [], diffs: {} });
return;
}
// Use stored modifiedFiles if available, otherwise compute on-the-fly
let files = task.modifiedFiles;
if (!files || files.length === 0) {
// Fallback: compute files using git diff
try {
const baseRef = task.baseCommitSha ?? "HEAD~1";
const output = execSync(`git diff --name-only ${baseRef}..HEAD`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = output ? output.split("\n").filter(Boolean) : [];
} catch {
files = [];
}
}
if (files.length === 0) {
res.json({ files: [], diffs: {} });
return;
}
// Compute diffs for each file
const diffs: Record<string, { stat: string; patch: string }> = {};
const baseRef = task.baseCommitSha ?? "HEAD~1";
for (const file of files) {
try {
// Get stat for this file
const stat = execSync(`git diff --stat ${baseRef}..HEAD -- "${file}"`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
// Get patch for this file
const patch = execSync(`git diff ${baseRef}..HEAD -- "${file}"`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 10000,
});
diffs[file] = { stat, patch };
} catch (err: any) {
// Log error but continue with other files
console.warn(`Failed to get diff for ${file}:`, err.message);
diffs[file] = { stat: "", patch: "" };
}
}
res.json({ files, diffs });
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/tasks/:id/workflow-results
* Get workflow step execution results for a task.

View File

@@ -1,9 +1,8 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import http from "node:http";
import { createServer } from "./server.js";
import type { TaskStore } from "@fusion/core";
import { get as performGet, request as performRequest } from "./test-request.js";
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
@@ -24,43 +23,63 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
async function GET(app: ReturnType<typeof createServer>, path: string): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
const res = await performGet(app, path);
return res;
/** Helper: send GET and return { status, body, headers } */
async function GET(app: express.Express, path: string): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const addr = server.address() as { port: number };
http.get(`http://127.0.0.1:${addr.port}${path}`, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data), headers: res.headers });
} catch {
resolve({ status: res.statusCode!, body: data, headers: res.headers });
}
});
}).on("error", (err) => { server.close(); reject(err); });
});
});
}
async function REQUEST(
app: ReturnType<typeof createServer>,
app: express.Express,
method: string,
path: string,
body?: string,
headers?: Record<string, string>,
): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
return performRequest(app, method, path, body, headers);
): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const addr = server.address() as { port: number };
const req = http.request(
{ hostname: "127.0.0.1", port: addr.port, path, method, headers },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data), headers: res.headers });
} catch {
resolve({ status: res.statusCode!, body: data, headers: res.headers });
}
});
},
);
req.on("error", (err) => { server.close(); reject(err); });
if (body) req.write(body);
req.end();
});
});
}
describe("API Error Handling Middleware", () => {

View File

@@ -176,280 +176,6 @@ describe("usage", () => {
expect(sessionWindow!.resetText).toContain("resets in");
});
it("handles 429 rate limit with retry - succeeds on second attempt", async () => {
// Use fake timers for controlled retry delays
vi.useFakeTimers();
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
return JSON.stringify({
accessToken: "test-token",
scopes: ["user:profile"],
});
}
throw new Error("File not found");
});
const mockResponse = {
five_hour: { utilization: 50, resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() },
};
let requestCount = 0;
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((options: any, callback: any) => {
requestCount++;
const mockRes = {
statusCode: requestCount === 1 ? 429 : 200, // First request fails with 429
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(requestCount === 1 ? { error: "rate limited" } : mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providersPromise = fetchAllProviderUsage();
// Advance timers to let retry delays complete (1s for first retry)
await vi.advanceTimersByTimeAsync(1000);
const providers = await providersPromise;
const claude = providers.find((p) => p.name === "Claude")!;
expect(requestCount).toBe(2); // Initial + 1 retry
expect(claude.status).toBe("ok");
expect(claude.windows).toHaveLength(1);
vi.useRealTimers();
});
it("fails after max retries exhausted on 429", async () => {
vi.useFakeTimers();
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
return JSON.stringify({
accessToken: "test-token",
scopes: ["user:profile"],
});
}
throw new Error("File not found");
});
let requestCount = 0;
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((options: any, callback: any) => {
requestCount++;
const mockRes = {
statusCode: 429, // Always rate limited
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from('{"error": "rate limited"}'));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providersPromise = fetchAllProviderUsage();
// Advance through all retry delays: 1s + 2s + 4s = 7s
await vi.advanceTimersByTimeAsync(7000);
const providers = await providersPromise;
const claude = providers.find((p) => p.name === "Claude")!;
expect(requestCount).toBe(3); // Max 3 attempts
expect(claude.status).toBe("error");
expect(claude.error).toContain("Rate limited by Anthropic API");
expect(claude.error).toContain("please try again in a few moments");
vi.useRealTimers();
});
it("does not retry on 401 auth errors - fails immediately", async () => {
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
return JSON.stringify({
accessToken: "expired-token",
scopes: ["user:profile"],
});
}
throw new Error("File not found");
});
let requestCount = 0;
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((options: any, callback: any) => {
requestCount++;
const mockRes = {
statusCode: 401,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from('{"error": "unauthorized"}'));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(requestCount).toBe(1); // No retries
expect(claude.status).toBe("error");
expect(claude.error).toContain("Auth expired");
});
it("does not retry on 403 auth errors - fails immediately", async () => {
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
return JSON.stringify({
accessToken: "forbidden-token",
scopes: ["user:profile"],
});
}
throw new Error("File not found");
});
let requestCount = 0;
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((options: any, callback: any) => {
requestCount++;
const mockRes = {
statusCode: 403,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from('{"error": "forbidden"}'));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(requestCount).toBe(1); // No retries
expect(claude.status).toBe("error");
expect(claude.error).toContain("Auth expired");
});
it("does not retry on 5xx server errors - fails immediately", async () => {
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
return JSON.stringify({
accessToken: "test-token",
scopes: ["user:profile"],
});
}
throw new Error("File not found");
});
let requestCount = 0;
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((options: any, callback: any) => {
requestCount++;
const mockRes = {
statusCode: 503, // Service unavailable
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from('{"error": "service unavailable"}'));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(requestCount).toBe(1); // No retries
expect(claude.status).toBe("error");
expect(claude.error).toBe("HTTP 503");
});
it("retries with exponential backoff delays (1s, 2s, 4s)", async () => {
vi.useFakeTimers();
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
return JSON.stringify({
accessToken: "test-token",
scopes: ["user:profile"],
});
}
throw new Error("File not found");
});
let requestCount = 0;
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
mockRequest.mockImplementation((options: any, callback: any) => {
requestCount++;
const mockRes = {
statusCode: 429,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from('{"error": "rate limited"}'));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providersPromise = fetchAllProviderUsage();
// Advance through all retry delays: 1s + 2s + 4s = 7s
await vi.advanceTimersByTimeAsync(7000);
const providers = await providersPromise;
const claude = providers.find((p) => p.name === "Claude")!;
// Should make 3 attempts (initial + 2 retries) with exponential backoff
expect(requestCount).toBe(3);
expect(claude.status).toBe("error");
expect(claude.error).toContain("Rate limited by Anthropic API");
vi.useRealTimers();
});
it("handles 401 auth error", async () => {
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {

View File

@@ -207,24 +207,8 @@ function decodeJwtPayload(token: string): any {
}
}
/**
* Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ── Claude fetcher ─────────────────────────────────────────────────────────
/**
* Fetch Claude usage data from Anthropic API.
*
* Implements retry logic with exponential backoff for rate limit (429) errors:
* - Max 3 attempts total (initial + 2 retries)
* - Delays: 1s, 2s, 4s (exponential backoff)
* - Auth errors (401/403) and server errors (5xx) fail immediately without retry
* - After max retries exhausted, returns user-friendly rate limit error
*/
async function fetchClaudeUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Claude",
@@ -271,98 +255,78 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
else usage.plan = oauthCreds.rateLimitTier;
}
// Retry logic with exponential backoff for 429 errors
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000; // 1s, 2s, 4s
try {
const res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
authorization: `Bearer ${oauthCreds.accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
},
});
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
authorization: `Bearer ${oauthCreds.accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
},
});
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — run 'claude' to re-login";
return usage;
}
if (res.status === 429) {
// Rate limited - retry with exponential backoff (1s, 2s, 4s)
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1); // 1s, 2s, 4s
await sleep(delayMs);
continue; // Retry
}
// All retries exhausted
usage.status = "error";
usage.error = "Rate limited by Anthropic API — please try again in a few moments";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}`;
return usage;
}
const data = JSON.parse(res.body);
usage.status = "ok";
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const parseWindow = (key: string, label: string, windowDurationMs: number): UsageWindow | null => {
const w = data[key];
if (!w || typeof w !== "object") return null;
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? 0;
let resetText: string | null = null;
let resetMs: number | undefined;
const resetAt = w.resets_at || w.reset_at || w.resetAt;
if (resetAt) {
const msLeft = new Date(resetAt).getTime() - Date.now();
resetMs = msLeft > 0 ? msLeft : 0;
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
}
return {
label,
percentUsed: Math.min(100, Math.max(0, pctUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
resetText,
windowDurationMs,
resetMs,
};
};
const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS);
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
if (fiveHour) usage.windows.push(fiveHour);
if (sevenDay) usage.windows.push(sevenDay);
if (sonnet) usage.windows.push(sonnet);
if (opus) usage.windows.push(opus);
// Success - exit retry loop
return usage;
} catch (e: any) {
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = e.message || "Failed to fetch";
usage.error = "Auth expired — run 'claude' to re-login";
return usage;
}
if (res.status === 429) {
usage.status = "error";
usage.error = "Rate limited — try again later";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}`;
return usage;
}
const data = JSON.parse(res.body);
usage.status = "ok";
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const parseWindow = (key: string, label: string, windowDurationMs: number): UsageWindow | null => {
const w = data[key];
if (!w || typeof w !== "object") return null;
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? 0;
let resetText: string | null = null;
let resetMs: number | undefined;
const resetAt = w.resets_at || w.reset_at || w.resetAt;
if (resetAt) {
const msLeft = new Date(resetAt).getTime() - Date.now();
resetMs = msLeft > 0 ? msLeft : 0;
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now";
}
return {
label,
percentUsed: Math.min(100, Math.max(0, pctUsed)),
percentLeft: Math.min(100, Math.max(0, 100 - pctUsed)),
resetText,
windowDurationMs,
resetMs,
};
};
const fiveHour = parseWindow("five_hour", "Session (5h)", FIVE_HOURS_MS);
const sevenDay = parseWindow("seven_day", "Weekly", SEVEN_DAYS_MS);
const sonnet = parseWindow("seven_day_sonnet", "Weekly (Sonnet)", SEVEN_DAYS_MS);
const opus = parseWindow("seven_day_opus", "Weekly (Opus)", SEVEN_DAYS_MS);
if (fiveHour) usage.windows.push(fiveHour);
if (sevenDay) usage.windows.push(sevenDay);
if (sonnet) usage.windows.push(sonnet);
if (opus) usage.windows.push(opus);
} catch (e: any) {
usage.status = "error";
usage.error = e.message || "Failed to fetch";
}
// Should not reach here, but return error just in case
usage.status = "error";
usage.error = "Rate limited by Anthropic API — please try again in a few moments";
return usage;
}