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:
@@ -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" });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user