diff --git a/.gitignore b/.gitignore index e60d5d6..256b903 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ venv/ # Build artifacts *.vsix +*.tsbuildinfo # ReCost generated output files .recost-context.md diff --git a/CLAUDE.md b/CLAUDE.md index 682e6a0..fefac14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,7 @@ Two separate key systems coexist: **ReCost API key** (for scanning/remote API) — managed entirely in `extension.ts`: - Stored in `context.secrets` under `"recost.apiKey"` - Must begin with `rc-` prefix — validated before storing -- `validateRcApiKey()` in `api-client.ts` calls `GET /auth/me` with `Authorization: Bearer `; returns `null` on 404 (dev mode), throws on 401 (invalid) or network error +- `validateApiKey()` in `api-client.ts` calls `GET /auth/me` with `Authorization: Bearer `; returns the authenticated user on 200, throws on any non-2xx (including 401 invalid and 404) - Status bar item reflects auth state with color: green (`testing.iconPassed`) when connected, `statusBarItem.warningForeground` when unreachable, no color when unconfigured; clicking it runs `recost.openKeys` - `context.secrets.onDidChange` listener keeps status bar live without reload - After key validation in the webview (`serviceId === "recost"`), `recost.keyOnline` context is also updated so the status bar stays in sync diff --git a/docs/superpowers/plans/2026-05-23-wave8-status-error-ux.md b/docs/superpowers/plans/2026-05-23-wave8-status-error-ux.md new file mode 100644 index 0000000..8bf07b2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-wave8-status-error-ux.md @@ -0,0 +1,869 @@ +# Wave 8 — Status / Error UX Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close #94 (404-as-valid fail-open), #100 (silent 429 on scan submit), and #46 (status-bar indicator flicker) in one bundled PR. + +**Architecture:** Three small, independent fixes share the same auth/indicator surface, so they ship together with a single test pass. +1. `validateApiKey()` drops the 404 → null branch and now propagates 404 like any other error. +2. `apiFetch()` annotates 429 errors with a parsed `retryAfterSeconds`; the scan-publishing catch-block surfaces a `scanNotification` and still publishes local results. +3. The status-bar refresh path gains (a) a generation counter to drop stale writes, (b) a 60-second debounce on `windowFocused` triggers only, (c) deletion of the workspace-folders trigger, and (d) a `refreshStatusBar` callback wired through `ReCostSidebarProvider` so the scan-side auth-failure branch can refresh the status bar without waiting for focus. + +**Tech Stack:** TypeScript (strict), Node test runner via `node:assert/strict`, `tsc -p tsconfig.scanner-tests.json`, esbuild for extension build. + +--- + +## File Structure + +| File | Change | +|------|--------| +| `src/api-client.ts` | Remove 404 branch in `validateApiKey`; drop `\| null` from return type; refresh JSDoc. In `apiFetchWith`, parse `Retry-After` on 429 and attach `err.retryAfterSeconds`. | +| `src/extension.ts` | Add `validationGeneration` + `lastValidationAt` module-level state. Insert generation check in `updateStatusBar`. Add focus debounce in `scheduleKeyIndicatorRefresh`. Delete `onDidChangeWorkspaceFolders` subscription. Collapse the `if (user)` branch. Export `scheduleKeyIndicatorRefresh` indirectly to the provider via a callback. | +| `src/webview-provider.ts` | Constructor accepts an optional `refreshStatusBar?: () => void`. Wire it through to `ScanPublishingHandler` via the context. | +| `src/webview/scan-publishing-handler.ts` | Add `refreshStatusBar()` to `ScanPublishingHandlerContext`. Call it inside the auth-failure branch after `sendRecostKeyStatusUpdate()`. Add a new 429 branch above the auth-like check that posts `scanNotification` with Retry-After text and publishes local results. | +| `src/test/api-client.test.ts` | Drop the 404-returns-null test. Add: 404 throws, 429 attaches `retryAfterSeconds`, 429 without header attaches `undefined`. | +| `src/test/scan-publishing-handler.test.ts` (new) | Cover the 429 branch end-to-end via stubbed `submitScan` rejection. | +| `package.json` | Register the new test file under `scripts.test:scanner`. | +| `CLAUDE.md` | Rename `validateRcApiKey()` → `validateApiKey()` in the "Auth / API Key System" bullet; remove the dev-mode 404 wording. | + +No new modules. No removed files. No IPC message changes. + +--- + +## Task 1: Drop the 404-as-valid branch in `validateApiKey` + +**Files:** +- Modify: `src/api-client.ts:126-152` +- Test: `src/test/api-client.test.ts:81-90` (drop), then add new 404-throws case + +- [ ] **Step 1: Update the 404 test to expect a throw (failing)** + +Replace the existing case 5 block (lines 81-90 of `src/test/api-client.test.ts`) with: + +```ts + // 5. validateApiKey throws with status: 404 when /auth/me returns 404 + { + const restore = installFetch(() => new Response("", { status: 404 })); + try { + let caught: (Error & { status?: number }) | null = null; + try { + await validateApiKey("rc-validlooking"); + } catch (err) { + caught = err as Error & { status?: number }; + } + assert.ok(caught, "expected validateApiKey to throw on 404"); + assert.equal(caught!.status, 404); + } finally { + restore(); + } + } +``` + +- [ ] **Step 2: Run the test suite to verify it fails** + +Run: +```bash +npm run test:scanner 2>&1 | tail -40 +``` + +Expected: `api-client` test fails with `AssertionError [ERR_ASSERTION]: expected validateApiKey to throw on 404`. Every other test still passes. + +- [ ] **Step 3: Update `validateApiKey` in `src/api-client.ts`** + +Replace lines 126-152 (the `AuthMeUser` interface, JSDoc, and function) with: + +```ts +export interface AuthMeUser { + email: string; +} + +/** + * Validates an API key against GET /auth/me. + * Returns AuthMeUser on success. + * Throws with err.status === 401 for invalid key. + * Throws with err.status === for other HTTP errors (including 404). + * Throws without .status for network errors. + */ +export async function validateApiKey(key: string): Promise { + if (!key.startsWith("rc-")) { + const err = new Error("Invalid ReCost API key — keys must start with rc-") as Error & { status: number }; + err.status = 401; + throw err; + } + const { data } = await apiFetch<{ data: AuthMeUser }>("/auth/me", undefined, key); + return data; +} +``` + +- [ ] **Step 4: Run the test suite to verify it passes** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: All tests pass. The line `PASS api-client` appears in the output. + +- [ ] **Step 5: Collapse the `if (user)` branch in `src/extension.ts`** + +`validateApiKey` no longer returns `null`, so the `else` branch on line 50-54 of `src/extension.ts` is dead. Replace lines 44-57 (`try { const user = await validateApiKey(key); ... return true; }`) with: + +```ts + try { + const user = await validateApiKey(key); + logStatus(output, `updateStatusBar: validateApiKey succeeded for ${user.email}; setting keyOnline=true`); + statusBar.text = `$(check) ReCost: ${user.email}`; + statusBar.tooltip = `Connected as ${user.email}`; + statusBar.color = new vscode.ThemeColor("testing.iconPassed"); + await vscode.commands.executeCommand("setContext", "recost.keyOnline", true); + return true; + } catch (err: unknown) { +``` + +The catch block on lines 58-93 is untouched — a 404 falls through to the `error.status !== 401` path (transient/network style) which uses the persisted-snapshot fallback. This is the intended behavior per the spec. + +- [ ] **Step 6: Verify the build still typechecks** + +Run: +```bash +npm run build:ext 2>&1 | tail -5 +``` + +Expected: build succeeds with no TypeScript errors. If TS complains that `user` could be `null` somewhere else, search for other call sites: `grep -rn "validateApiKey" src/`. Only `extension.ts` should call it. + +- [ ] **Step 7: Commit** + +```bash +git add src/api-client.ts src/extension.ts src/test/api-client.test.ts +git commit -m "fix(wave8): validateApiKey no longer treats 404 as valid (#94)" +``` + +--- + +## Task 2: Plumb `Retry-After` through `apiFetch` + +**Files:** +- Modify: `src/api-client.ts:13-34` (`apiFetchWith` error path) +- Test: `src/test/api-client.test.ts` — add two new cases at the end of `runTests()` (before `console.log("PASS api-client")`) + +- [ ] **Step 1: Write the failing tests** + +Add these two blocks immediately before the `console.log("PASS api-client");` line in `src/test/api-client.test.ts`: + +```ts + // 8. apiFetch attaches retryAfterSeconds on 429 with numeric Retry-After + { + const restore = installFetch( + () => + new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "Retry-After": "42" }, + }) + ); + try { + let caught: (Error & { status?: number; retryAfterSeconds?: number }) | null = null; + try { + await validateApiKey("rc-good"); + } catch (err) { + caught = err as Error & { status?: number; retryAfterSeconds?: number }; + } + assert.ok(caught, "expected validateApiKey to throw on 429"); + assert.equal(caught!.status, 429); + assert.equal(caught!.retryAfterSeconds, 42); + } finally { + restore(); + } + } + + // 9. apiFetch leaves retryAfterSeconds undefined when Retry-After is absent or non-numeric + { + const restore = installFetch( + () => + new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + }) + ); + try { + let caught: (Error & { status?: number; retryAfterSeconds?: number }) | null = null; + try { + await validateApiKey("rc-good"); + } catch (err) { + caught = err as Error & { status?: number; retryAfterSeconds?: number }; + } + assert.ok(caught, "expected validateApiKey to throw on 429"); + assert.equal(caught!.status, 429); + assert.equal(caught!.retryAfterSeconds, undefined); + } finally { + restore(); + } + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +npm run test:scanner 2>&1 | tail -20 +``` + +Expected: case 8 fails because `caught.retryAfterSeconds` is `undefined` instead of `42`. + +- [ ] **Step 3: Update `apiFetchWith` error path in `src/api-client.ts`** + +Replace lines 26-32 (the `if (!res.ok)` block) with: + +```ts + if (!res.ok) { + const body = await res.json().catch(() => ({})) as ApiError; + const msg = body?.error?.message ?? `API error ${res.status}`; + const err = new Error(msg) as Error & { status: number; retryAfterSeconds?: number }; + err.status = res.status; + if (res.status === 429) { + const header = res.headers.get("Retry-After"); + const parsed = header !== null ? Number.parseInt(header, 10) : NaN; + if (Number.isFinite(parsed) && parsed >= 0) { + err.retryAfterSeconds = parsed; + } + } + throw err; + } +``` + +Notes: +- Header parsing is defensive — RFC 7231 allows an HTTP-date, but our API emits integer seconds. Unparseable values silently drop to `undefined`, which the UI degrades to a generic "in a moment" message. +- `Number.parseInt` returns `NaN` for the empty string, so the `Number.isFinite` guard covers both "missing" and "unparseable." + +- [ ] **Step 4: Run the test to verify it passes** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: `PASS api-client` appears; all other tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/api-client.ts src/test/api-client.test.ts +git commit -m "feat(wave8): plumb Retry-After seconds through apiFetch 429 errors" +``` + +--- + +## Task 3: Add 429 branch to scan-publishing catch block + +**Files:** +- Modify: `src/webview/scan-publishing-handler.ts:778-814` +- Test: `src/test/scan-publishing-handler.test.ts` (new) +- Modify: `package.json` (add new test to `test:scanner`) + +- [ ] **Step 1: Create the failing test file** + +Create `src/test/scan-publishing-handler.test.ts`: + +```ts +import assert from "node:assert/strict"; +import Module from "node:module"; + +// Stub `vscode` before any dependency tries to require it. +const originalResolve = (Module as unknown as { + _resolveFilename: (req: string, parent: unknown) => string; +})._resolveFilename; +(Module as unknown as { + _resolveFilename: (req: string, parent: unknown) => string; +})._resolveFilename = function (request: string, parent: unknown) { + if (request === "vscode") return require.resolve("./vscode-stub"); + return originalResolve.call(this, request, parent); +}; + +// Stub the workspace-scanner before scan-publishing-handler is imported, so we +// can drive handleStartScan() without touching the real scanner. +const scannerStub = { + scanWorkspace: async () => [], + detectLocalWastePatterns: async () => [], + countScopedWorkspaceFiles: async () => 0, + getWorkspaceScanFiles: async () => [], +}; +require.cache[require.resolve("../scanner/workspace-scanner")] = { + id: require.resolve("../scanner/workspace-scanner"), + filename: require.resolve("../scanner/workspace-scanner"), + loaded: true, + exports: scannerStub, +} as unknown as NodeJS.Module; + +// Stub api-client.submitScan to reject with the status we want to test. +let nextScanError: (Error & { status?: number; retryAfterSeconds?: number }) | null = null; +require.cache[require.resolve("../api-client")] = { + id: require.resolve("../api-client"), + filename: require.resolve("../api-client"), + loaded: true, + exports: { + createProject: async () => "proj-stub", + submitScan: async () => { + if (nextScanError) throw nextScanError; + return { scanId: "scan-stub", summary: { totalEndpoints: 0, redundantCalls: 0, n1Suspects: 0, batchOpportunities: 0, cacheOpportunities: 0 } }; + }, + getAllEndpoints: async () => [], + getAllSuggestions: async () => [], + }, +} as unknown as NodeJS.Module; + +import { ScanPublishingHandler, type ScanPublishingHandlerContext } from "../webview/scan-publishing-handler"; +import type { HostMessage } from "../messages"; + +function makeCtx(posted: HostMessage[]): ScanPublishingHandlerContext { + const noop = async () => {}; + return { + postMessage: (m) => { posted.push(m); }, + context: { secrets: { get: async () => undefined }, globalState: { get: () => undefined, update: noop }, workspaceState: { get: () => undefined, update: noop } } as never, + setLastEndpoints: () => {}, + setLastSuggestions: () => {}, + setLastSummary: () => {}, + setLastApiCalls: () => {}, + setLastFindings: () => {}, + setProjectId: () => {}, + getProjectId: () => null, + getManualProjectId: () => null, + getRcApiKey: async () => "rc-good", + resolveScanProjectTarget: async () => ({ projectId: "proj-stub", source: "auto" }), + getWorkspaceName: () => "ws", + openKeys: () => {}, + setRecostValidationState: noop, + clearRecostValidationState: noop, + sendRecostKeyStatusUpdate: noop, + refreshStatusBar: () => {}, + resetChatHistory: () => {}, + exportDebugScanResults: noop, + pruneSavedScenariosAgainst: noop, + }; +} + +async function runTests() { + // 1. 429 with Retry-After: 42 surfaces a scanNotification with "42 seconds" and publishes local results + { + const posted: HostMessage[] = []; + const err = new Error("rate limited") as Error & { status: number; retryAfterSeconds: number }; + err.status = 429; + err.retryAfterSeconds = 42; + nextScanError = err; + const handler = new ScanPublishingHandler(makeCtx(posted)); + await handler.handleStartScan(); + const notification = posted.find((m) => m.type === "scanNotification") as { type: "scanNotification"; message: string } | undefined; + assert.ok(notification, "expected a scanNotification message"); + assert.match(notification!.message, /42 seconds/); + assert.match(notification!.message, /local results/i); + } + + // 2. 429 without Retry-After surfaces the generic "in a moment" message + { + const posted: HostMessage[] = []; + const err = new Error("rate limited") as Error & { status: number }; + err.status = 429; + nextScanError = err; + const handler = new ScanPublishingHandler(makeCtx(posted)); + await handler.handleStartScan(); + const notification = posted.find((m) => m.type === "scanNotification") as { type: "scanNotification"; message: string } | undefined; + assert.ok(notification, "expected a scanNotification message"); + assert.match(notification!.message, /in a moment/); + } + + // 3. 429 with retryAfterSeconds: 1 produces "1 second" (singular) + { + const posted: HostMessage[] = []; + const err = new Error("rate limited") as Error & { status: number; retryAfterSeconds: number }; + err.status = 429; + err.retryAfterSeconds = 1; + nextScanError = err; + const handler = new ScanPublishingHandler(makeCtx(posted)); + await handler.handleStartScan(); + const notification = posted.find((m) => m.type === "scanNotification") as { type: "scanNotification"; message: string } | undefined; + assert.ok(notification, "expected a scanNotification message"); + assert.match(notification!.message, /1 second\b/); + } + + // 4. 401 calls refreshStatusBar() exactly once after sendRecostKeyStatusUpdate + { + const posted: HostMessage[] = []; + let refreshCalls = 0; + let sentKeyUpdate = false; + let refreshedAfterUpdate = false; + const err = new Error("invalid auth") as Error & { status: number }; + err.status = 401; + nextScanError = err; + const ctx: ScanPublishingHandlerContext = { + ...makeCtx(posted), + sendRecostKeyStatusUpdate: async () => { sentKeyUpdate = true; }, + refreshStatusBar: () => { + refreshCalls++; + if (sentKeyUpdate) refreshedAfterUpdate = true; + }, + }; + const handler = new ScanPublishingHandler(ctx); + await handler.handleStartScan(); + assert.equal(refreshCalls, 1); + assert.equal(refreshedAfterUpdate, true, "refreshStatusBar must be called after sendRecostKeyStatusUpdate"); + } + + console.log("PASS scan-publishing-handler"); +} + +runTests().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +- [ ] **Step 2: Register the new test in `package.json`** + +Open `package.json`, find the `test:scanner` script (currently the only line inside `"scripts"` starting with `"test:scanner":`). Append ` && node dist-test/test/scan-publishing-handler.test.js` to the end of the command string (immediately before the closing `"`). Do not reorder existing entries. + +- [ ] **Step 3: Run the test to verify it fails** + +Run: +```bash +npm run test:scanner 2>&1 | tail -30 +``` + +Expected: `scan-publishing-handler` fails. The most likely failure is that `posted` contains no `scanNotification`, or `refreshStatusBar` is not part of the context type. If the failure is a TypeScript error about `refreshStatusBar` missing on `ScanPublishingHandlerContext`, that is exactly what we need to add in the next step. + +- [ ] **Step 4: Add `refreshStatusBar` to the handler context** + +In `src/webview/scan-publishing-handler.ts`, modify `ScanPublishingHandlerContext` (around line 45-66) — add the property `refreshStatusBar(): void;` immediately after `sendRecostKeyStatusUpdate`. The full added line: + +```ts + refreshStatusBar(): void; +``` + +- [ ] **Step 5: Insert the 429 branch and `refreshStatusBar` call** + +In `src/webview/scan-publishing-handler.ts`, replace lines 778-814 (the entire `catch (err: unknown) { ... }` block ending right before the outer try's closing `}`) with: + +```ts + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Remote analysis failed"; + const status = (err as { status?: number }).status; + + if (status === 429) { + const retryAfter = (err as { retryAfterSeconds?: number }).retryAfterSeconds; + const waitText = retryAfter !== undefined + ? `Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.` + : "Try again in a moment."; + this.ctx.postMessage({ + type: "scanNotification", + message: `ReCost scan rate limit reached. ${waitText} Showing local results.`, + }); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + return; + } + + const authLikeFailure = + status === 401 || + (status === 403 && /invalid|unauthori[sz]ed|forbidden|auth/i.test(message)); + + if (authLikeFailure) { + const rcKey = await this.ctx.getRcApiKey(); + if (rcKey) { + await this.ctx.setRecostValidationState({ + state: "invalid", + message, + lastCheckedAt: new Date().toISOString(), + keyFingerprint: buildKeyFingerprint(rcKey), + }); + } else { + await this.ctx.clearRecostValidationState(); + } + await this.ctx.sendRecostKeyStatusUpdate(); + this.ctx.refreshStatusBar(); + this.ctx.openKeys("recost"); + } + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + if (status === 404 && manualProjectId) { + this.ctx.postMessage({ + type: "scanNotification", + message: `Project ID ${manualProjectId} was not found. Keeping the saved manual Project ID and showing local results.`, + }); + return; + } + if (err instanceof Error && err.message === "fetch failed") { + this.ctx.postMessage({ + type: "scanNotification", + message: "Could not reach ReCost server. Showing local results.", + }); + } + } +``` + +Placement reminders (already reflected above): +- 429 branch runs **before** the auth-like check. No overlap because 429 ≠ 401/403. +- 429 branch calls `publishLocalOnlyResults` explicitly and `return`s — none of the later branches run. +- `this.ctx.refreshStatusBar()` runs inside `authLikeFailure`, immediately after `sendRecostKeyStatusUpdate()`. + +- [ ] **Step 6: Provide a stub `refreshStatusBar` in `webview-provider.ts`** + +The provider must satisfy the new context property. Open `src/webview-provider.ts` and modify the `new ScanPublishingHandler({ ... })` literal (lines 236-257). Add this line right after `sendRecostKeyStatusUpdate: () => this.sendKeyStatusUpdate("recost", "recost"),` (currently line 253): + +```ts + refreshStatusBar: () => { this.refreshStatusBar?.(); }, +``` + +Then add a private optional field near the top of the class (right before the constructor, after the other private declarations). Search for `private readonly scanPublishingHandler: ScanPublishingHandler;` and add right after it: + +```ts + private refreshStatusBar?: () => void; +``` + +Modify the constructor signature on line 198 from: +```ts + constructor(context: vscode.ExtensionContext) { +``` +to: +```ts + constructor(context: vscode.ExtensionContext, refreshStatusBar?: () => void) { +``` + +And inside the constructor body, immediately after `this.context = context;` (line 199), add: +```ts + this.refreshStatusBar = refreshStatusBar; +``` + +- [ ] **Step 7: Wire the callback in `extension.ts`** + +Open `src/extension.ts`. Find line 152: `const provider = new ReCostSidebarProvider(context);` and replace with: + +```ts + const provider = new ReCostSidebarProvider(context, () => { + scheduleKeyIndicatorRefresh(statusBar, context, statusOutput, "scanAuthFailure"); + }); +``` + +The callback can reference `statusBar`, `context`, and `statusOutput` because line 152 is after their declarations (lines 135-152). + +- [ ] **Step 8: Run the test to verify it passes** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: `PASS scan-publishing-handler` plus all other tests pass. + +- [ ] **Step 9: Verify the build still typechecks** + +Run: +```bash +npm run build 2>&1 | tail -10 +``` + +Expected: webview + extension build succeed. + +- [ ] **Step 10: Commit** + +```bash +git add src/webview/scan-publishing-handler.ts src/webview-provider.ts src/extension.ts src/test/scan-publishing-handler.test.ts package.json +git commit -m "feat(wave8): surface 429 with Retry-After and refresh status bar on scan auth failure (#100, #46 Bug A)" +``` + +--- + +## Task 4: Generation counter — drop stale status-bar writes (#46 Bug B) + +**Files:** +- Modify: `src/extension.ts:22-94` (module state + `updateStatusBar`) + +This task has no isolated unit test that runs reliably in our existing test harness (the status-bar state lives on a vscode-only object and `updateStatusBar` is not exported). The manual EDH gate B ("Alt-tab away and back 5x rapidly — no flicker") is the regression check. We still implement and reason about it carefully because the bug is real. + +- [ ] **Step 1: Add module-level generation state** + +In `src/extension.ts`, immediately after line 22 (`let activePricingSyncIntervalId: ReturnType | null = null;`), add: + +```ts +let validationGeneration = 0; +let lastValidationAt = 0; +``` + +- [ ] **Step 2: Insert generation captures + checks inside `updateStatusBar`** + +In `src/extension.ts`, replace the whole `updateStatusBar` function (lines 30-94) with the version below. The changes vs current: capture `myGen` at the top; after each `await`, bail out via `return` (using whatever value the snapshot would have produced — but since we're stale, we no-op and return the prior value) before writing to `statusBar.*`; set `lastValidationAt` at the very end of every code path. + +```ts +async function updateStatusBar( + statusBar: vscode.StatusBarItem, + context: vscode.ExtensionContext, + output: vscode.OutputChannel +): Promise { + const myGen = ++validationGeneration; + const key = await readStoredSecret(getKeyService("recost"), context.secrets); + if (myGen !== validationGeneration) return false; + if (!key) { + logStatus(output, "updateStatusBar: no stored ReCost key; setting keyOnline=false"); + statusBar.text = "$(key) ReCost: Not Configured"; + statusBar.tooltip = "Click to manage your ReCost API keys"; + statusBar.color = undefined; + await vscode.commands.executeCommand("setContext", "recost.keyOnline", false); + lastValidationAt = Date.now(); + return false; + } + try { + const user = await validateApiKey(key); + if (myGen !== validationGeneration) return true; + logStatus(output, `updateStatusBar: validateApiKey succeeded for ${user.email}; setting keyOnline=true`); + statusBar.text = `$(check) ReCost: ${user.email}`; + statusBar.tooltip = `Connected as ${user.email}`; + statusBar.color = new vscode.ThemeColor("testing.iconPassed"); + await vscode.commands.executeCommand("setContext", "recost.keyOnline", true); + lastValidationAt = Date.now(); + return true; + } catch (err: unknown) { + if (myGen !== validationGeneration) return false; + const error = err as Error & { status?: number }; + if (error.status === 401) { + const hasSnapshot = await hasPersistedValidEcoKey(context, output); + if (myGen !== validationGeneration) return false; + if (hasSnapshot) { + logStatus(output, `updateStatusBar: validateApiKey returned 401 (${error.message}) but a persisted valid snapshot still exists; keeping keyOnline=true`); + statusBar.text = "$(warning) ReCost: Auth Check Failed"; + statusBar.tooltip = "Stored ReCost key was previously validated, but the latest background auth check returned 401."; + statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); + await vscode.commands.executeCommand("setContext", "recost.keyOnline", true); + lastValidationAt = Date.now(); + return true; + } + logStatus(output, `updateStatusBar: validateApiKey returned 401 (${error.message}); setting keyOnline=false`); + statusBar.text = "$(warning) ReCost: Invalid Key"; + statusBar.tooltip = "ReCost API key is invalid. Click to manage keys."; + statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); + await vscode.commands.executeCommand("setContext", "recost.keyOnline", false); + lastValidationAt = Date.now(); + return false; + } + + const hasSnapshot = await hasPersistedValidEcoKey(context, output); + if (myGen !== validationGeneration) return false; + if (hasSnapshot) { + logStatus(output, `updateStatusBar: validateApiKey failed transiently (${error.message}); keeping keyOnline=true from persisted valid snapshot`); + statusBar.text = "$(check) ReCost: Connected"; + statusBar.tooltip = "ReCost key is stored and was previously validated. ReCost is temporarily unreachable."; + statusBar.color = new vscode.ThemeColor("testing.iconPassed"); + await vscode.commands.executeCommand("setContext", "recost.keyOnline", true); + lastValidationAt = Date.now(); + return true; + } else { + logStatus(output, `updateStatusBar: validateApiKey failed without trusted snapshot (${error.message}); setting keyOnline=false`); + statusBar.text = "$(warning) ReCost: Unreachable"; + statusBar.tooltip = "Cannot reach ReCost. Check your connection."; + statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); + await vscode.commands.executeCommand("setContext", "recost.keyOnline", false); + lastValidationAt = Date.now(); + return false; + } + } +} +``` + +Note: each `await` boundary that could race with a newer call is followed by a generation check. Stale completions silently no-op and return the most-recent-return value (which is then discarded by the fire-and-forget caller). + +- [ ] **Step 3: Verify the build typechecks** + +Run: +```bash +npm run build:ext 2>&1 | tail -5 +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/extension.ts +git commit -m "fix(wave8): drop stale status-bar writes via generation counter (#46 Bug B)" +``` + +--- + +## Task 5: Debounce window-focus trigger (#46 Bug C) + +**Files:** +- Modify: `src/extension.ts:119-132` (`scheduleKeyIndicatorRefresh`) + +- [ ] **Step 1: Add the debounce check** + +Replace the entire `scheduleKeyIndicatorRefresh` function (lines 119-132) with: + +```ts +function scheduleKeyIndicatorRefresh( + statusBar: vscode.StatusBarItem, + context: vscode.ExtensionContext, + output: vscode.OutputChannel, + reason: string +): void { + if (reason === "windowFocused" && Date.now() - lastValidationAt < 60_000) { + logStatus(output, `scheduleKeyIndicatorRefresh: debounced reason=${reason}`); + return; + } + void (async () => { + logStatus(output, `scheduleKeyIndicatorRefresh: begin reason=${reason}`); + await updateStatusBar(statusBar, context, output); + logStatus(output, `scheduleKeyIndicatorRefresh: end reason=${reason} text="${statusBar.text}"`); + })().catch((err: unknown) => { + logStatus(output, `scheduleKeyIndicatorRefresh: error reason=${reason} message=${err instanceof Error ? err.message : String(err)}`); + }); +} +``` + +Notes: +- The debounce only applies to `reason === "windowFocused"`. All other reasons (`activate`, `openPanel`, `openKeys`, `scanWorkspace`, `scanAuthFailure`) bypass it — they are explicit user intent or state-changing events. +- `lastValidationAt` is set at the end of every `updateStatusBar` path (Task 4). On first activate it is `0`, so `Date.now() - 0 ≥ 60_000` is true on cold start and the first `windowFocused` after activate still fires. + +- [ ] **Step 2: Verify the build typechecks** + +Run: +```bash +npm run build:ext 2>&1 | tail -5 +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/extension.ts +git commit -m "fix(wave8): debounce windowFocused status-bar refresh to 60s (#46 Bug C)" +``` + +--- + +## Task 6: Delete workspace-folders trigger (#46 Bug D) + +**Files:** +- Modify: `src/extension.ts:250-254` + +- [ ] **Step 1: Remove the subscription** + +Delete lines 250-254 of `src/extension.ts`: + +```ts + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + scheduleKeyIndicatorRefresh(statusBar, context, statusOutput, "workspaceFoldersChanged"); + }) + ); +``` + +After deletion, the surrounding code goes directly from the `onDidChangeWindowState` subscription block (ending around line 249) to the `statusOnlineCommand` declaration (currently line 256). No comma fixups or scope changes needed — the subscription pushes are independent. + +- [ ] **Step 2: Verify the build typechecks** + +Run: +```bash +npm run build:ext 2>&1 | tail -5 +``` + +Expected: build succeeds with no unused-import warnings. + +- [ ] **Step 3: Commit** + +```bash +git add src/extension.ts +git commit -m "fix(wave8): drop workspace-folders status-bar trigger (#46 Bug D)" +``` + +--- + +## Task 7: Update CLAUDE.md naming + +**Files:** +- Modify: `CLAUDE.md:207` + +- [ ] **Step 1: Rewrite the bullet** + +In `CLAUDE.md`, find line 207: +``` +- `validateRcApiKey()` in `api-client.ts` calls `GET /auth/me` with `Authorization: Bearer `; returns `null` on 404 (dev mode), throws on 401 (invalid) or network error +``` + +Replace with: +``` +- `validateApiKey()` in `api-client.ts` calls `GET /auth/me` with `Authorization: Bearer `; returns the authenticated user on 200, throws on any non-2xx (including 401 invalid and 404) +``` + +This corrects the name confusion (`validateRcApiKey` hits `/projects?limit=1`, not `/auth/me`) and removes the now-incorrect 404-as-dev-mode wording. + +- [ ] **Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs(wave8): correct CLAUDE.md auth-section naming and behavior" +``` + +--- + +## Task 8: Final verification + +- [ ] **Step 1: Full test suite** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: every line ending in `PASS …` for the existing suite, plus `PASS api-client` and `PASS scan-publishing-handler`. No failures. + +- [ ] **Step 2: Full build** + +Run: +```bash +npm run build 2>&1 | tail -10 +``` + +Expected: dashboard + webview + extension builds clean. No TypeScript errors. + +- [ ] **Step 3: D1 benchmark sanity check** + +If the benchmark gate is reachable from this repo, run: +```bash +npm run bench 2>&1 | tail -20 +``` + +Expected: Δ +0.00pp (this PR does not touch the scanner). If `npm run bench` does not exist here, skip this step — it is a sanity check, not a hard gate. + +- [ ] **Step 4: Open the PR** + +Create a feature branch from the work done above (if not already on one) and open the PR: + +```bash +# If still on main, move commits to a branch: +git checkout -b wave8/status-error-ux + +git push -u origin wave8/status-error-ux + +gh pr create --title "wave8: status/error UX (#94, #100, #46)" --body "$(cat <<'EOF' +## Summary +- #94: `validateApiKey()` no longer treats `/auth/me` 404 as valid in dev mode. +- #100: scan-submit 429 surfaces a `scanNotification` with `Retry-After` text; local results still publish. +- #46: status-bar indicator stops flickering — generation counter, focus debounce, workspace-folders trigger removed, scan-side auth failure now refreshes the status bar. + +## Test plan +- [ ] `npm run test:scanner` passes (existing + new `api-client` + new `scan-publishing-handler`) +- [ ] `npm run build` clean +- [ ] D1 benchmark Δ +0.00pp (sanity) +- [ ] EDH gate 1 — Revoke key in dashboard → run scan → status bar flips to "Invalid Key" (not just sidebar). (Bug A) +- [ ] EDH gate 2 — Alt-tab away and back 5x rapidly while connected → status stays "Connected", no flicker. (Bugs B + C) +- [ ] EDH gate 3 — Configure a fresh key whose `/auth/me` returns 404 → status bar shows "Invalid Key", not "Connected". (#94) +- [ ] EDH gate 4 — Submit 11 scans within 60s → 11th run shows the rate-limit `scanNotification` with a wait time. (#100) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Return the PR URL. + +--- + +## Self-review notes + +- **Spec coverage:** Sections A (#94), B (#100), C (#46 Bugs A/B/C/D) and the CLAUDE.md naming fix all have tasks. Section A → Task 1. Section B → Tasks 2 + 3. Section C Bug A → Task 3 Steps 4-7. Section C Bug B → Task 4. Section C Bug C → Task 5. Section C Bug D → Task 6. CLAUDE.md fix → Task 7. Verification gates → Task 8. +- **Tests covered:** Spec asks for (a) 404-throws on `validateApiKey` (Task 1), (b) 429 Retry-After parsing (Task 2), (c) 429 scanNotification with seconds + generic fallback (Task 3), (d) status-bar refresh callback invoked on 401 (Task 3 case 4). The "generation counter race" unit test the spec floats was explicitly noted as droppable in favor of manual gate B — Task 4 follows that path. +- **Type consistency:** `refreshStatusBar` is a `() => void` throughout — context interface, provider constructor, extension.ts wire-up. `retryAfterSeconds` is a `number | undefined` on both `apiFetch` errors and the 429 catch branch. +- **Risk:** Task 3 Step 1's `require.cache` stubbing is fragile if module paths change. If the test ever fails to stub correctly, fall back to factoring out an explicit "fetch-failed scan handler" function with DI — but try the stub-first approach first since it matches the style of `webview-provider-dispatch.test.ts`. diff --git a/docs/superpowers/specs/2026-05-22-wave8-status-error-ux-design.md b/docs/superpowers/specs/2026-05-22-wave8-status-error-ux-design.md new file mode 100644 index 0000000..281f80c --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-wave8-status-error-ux-design.md @@ -0,0 +1,244 @@ +# Wave 8 — Status / Error UX Design + +**Date:** 2026-05-22 +**Closes:** #94, #100, #46 +**Wave label:** `wave/8-status-error-ux` +**Area:** `area/extension-ux` +**PR shape:** one bundled PR + +## Goal + +Three related defects on the same auth/indicator surface ship together: + +1. **#94** — `validateApiKey()` treats a 404 from `/auth/me` as "valid in dev mode," fail-open at the auth boundary. +2. **#100** — Scan submission catches a 429 from the API and surfaces nothing to the user. +3. **#46** — The status-bar indicator switches state randomly and doesn't always update when it should. + +## Non-goals + +- SDK-side 429 handling (tracked separately in `middleware-node` and `middleware-python`). +- State-machine overhaul of the indicator. The current five-state model is fine; only the trigger plumbing is broken. +- Any change to `validateRcApiKey()` (the function that hits `/projects?limit=1`). It has no bug. We only clean up CLAUDE.md naming confusion that swapped the two functions. + +## Architecture + +All changes live in four files plus tests: + +- `src/api-client.ts` — `validateApiKey()` and `apiFetch()` +- `src/extension.ts` — status-bar refresh plumbing +- `src/webview-provider.ts` — wire `refreshStatusBar` through the context interface that scan-publishing-handler consumes +- `src/webview/scan-publishing-handler.ts` — scan-error catch block +- CLAUDE.md — naming fix in the "Auth / API Key System" section + +No new modules, no IPC surface changes, no scanner/detector impact. + +--- + +## Section A — #94: remove the 404-as-valid branch + +### Current behavior + +`validateApiKey()` at `src/api-client.ts:136-152` calls `GET /auth/me` and: + +- 200 → returns `AuthMeUser`. +- 401 → throws (status: 401). +- 404 → returns `null`, treated as valid ("dev mode"). +- Network error → throws (no status). + +The 404 branch is a fail-open at the auth boundary. Any future API regression that makes `/auth/me` 404 silently marks every key as valid. + +### Change + +```ts +export async function validateApiKey(key: string): Promise { + if (!key.startsWith("rc-")) { + const err = new Error("Invalid ReCost API key — keys must start with rc-") as Error & { status: number }; + err.status = 401; + throw err; + } + const { data } = await apiFetch<{ data: AuthMeUser }>("/auth/me", undefined, key); + return data; +} +``` + +- Return type drops `| null`. +- Try/catch removed; 404 now propagates normally via `apiFetch`'s default behavior (`status: 404`, message `API error 404`). +- JSDoc updated to remove dev-mode mention. + +### Callsite update — `src/extension.ts:44-94` + +The `if (user) { ... } else { ... }` branch on `validateApiKey`'s return collapses because `user` is always present on success. The email-bearing status text becomes unconditional. + +In the catch block, a 404 falls into the `error.status !== 401` path — meaning it's treated as a transient/network-style error and goes through the persisted-snapshot fallback. This is intentional: + +- One-off 404 on a previously-valid key → grace period via snapshot. +- 404 on a brand-new key (no snapshot) → marks invalid. + +### CLAUDE.md fix + +The "Auth / API Key System" section currently says `validateRcApiKey()` calls `/auth/me`. It doesn't — `validateApiKey()` does. Rename in the doc to remove the cross-reference confusion. + +### Tests + +`src/test/api-client.test.ts`: +- Drop any test that asserts 404 → null on `validateApiKey`. +- Add: `validateApiKey` throws with `status: 404` when the backend returns 404. + +--- + +## Section B — #100: surface 429 with Retry-After + +### Current behavior + +The scan-submission catch block at `src/webview/scan-publishing-handler.ts:778-814` handles: + +- 401 / 403 (auth-like) — invalidates key, opens Keys tab. +- 404 with manual project ID — posts a scanNotification. +- `"fetch failed"` — posts a scanNotification. + +A 429 falls through with no notification. `publishLocalOnlyResults` runs earlier in the catch chain so the user does see local findings — but no signal that they were rate-limited. + +### Change 1 — plumb Retry-After + +`src/api-client.ts:26-32` (`apiFetch` error path): + +```ts +if (!res.ok) { + const body = await res.json().catch(() => ({})) as ApiError; + const msg = body?.error?.message ?? `API error ${res.status}`; + const err = new Error(msg) as Error & { status: number; retryAfterSeconds?: number }; + err.status = res.status; + if (res.status === 429) { + const header = res.headers.get("Retry-After"); + const parsed = header !== null ? Number.parseInt(header, 10) : NaN; + if (Number.isFinite(parsed) && parsed >= 0) { + err.retryAfterSeconds = parsed; + } + } + throw err; +} +``` + +Defensive parsing: header can also be an HTTP-date per RFC 7231, but our API emits seconds. Unparseable values are silently dropped — the message degrades to a generic "in a moment" form. + +### Change 2 — catch-block branch + +`src/webview/scan-publishing-handler.ts` — insert before the existing `authLikeFailure` check: + +```ts +if (status === 429) { + const retryAfter = (err as { retryAfterSeconds?: number }).retryAfterSeconds; + const waitText = retryAfter !== undefined + ? `Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.` + : "Try again in a moment."; + this.ctx.postMessage({ + type: "scanNotification", + message: `ReCost scan rate limit reached. ${waitText} Showing local results.`, + }); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + return; +} +``` + +Placement notes: +- Goes **before** `authLikeFailure` (no overlap: 429 ≠ 401/403). +- Goes **before** the 404 / fetch-failed branches. +- Returns early so the rest of the catch block does not run. +- `publishLocalOnlyResults` called explicitly so the user still sees local findings — matches the existing pattern. + +### Tests + +Add a new `src/test/scan-publishing-handler.test.ts` (no existing test file for this handler; the closest, `webview-provider-dispatch.test.ts`, is scoped to IPC dispatch): +- Stub a 429 response with `Retry-After: 42`; assert the scanNotification message contains `42 seconds` and local results are published. +- Stub a 429 without `Retry-After`; assert the generic "in a moment" message. + +--- + +## Section C — #46: status-bar indicator audit + +The user-visible symptom is "state switches randomly and doesn't always switch." The audit found four concrete bugs. No state-machine overhaul; targeted fixes only. + +### Bug A — scan-side 401 doesn't refresh the status bar + +`src/webview/scan-publishing-handler.ts:788-797` updates `setValidationState` (persistence) and `sendRecostKeyStatusUpdate` (sidebar UI) on an auth-like scan failure. Neither touches the status bar. + +The status bar only refreshes via: +- `secrets.onDidChange` — but the secret didn't change here. +- `onDidChangeWindowState` focus. +- `onDidChangeWorkspaceFolders`. +- Manual commands. + +Result: revoked key surfaces in the sidebar but the status bar still shows "Connected" until the user alt-tabs away and back. + +**Fix:** add a `refreshStatusBar()` callback to the webview-provider context (the interface that already exposes `setRecostValidationState` and `sendRecostKeyStatusUpdate`). The activate-time wire-up in `extension.ts` provides an implementation that calls `scheduleKeyIndicatorRefresh(statusBar, context, statusOutput, "scanAuthFailure")`. Call it from the scan-side auth-failure handler immediately after `sendRecostKeyStatusUpdate()`. + +### Bug B — concurrent validations race + +`scheduleKeyIndicatorRefresh` is fire-and-forget; no in-flight guard. Two triggers close together start two `/auth/me` calls in parallel, and `statusBar.text`/`color` are written by whichever finishes last — not necessarily the most recent start. This produces "switching randomly" under any combination of (focus + secret change + command). + +**Fix:** generation counter inside `updateStatusBar`. A module-level `let validationGeneration = 0`. Each call captures `const myGen = ++validationGeneration` at the top. Before each write to `statusBar.text` / `statusBar.color` / `setContext("recost.keyOnline", …)`, the call checks `if (myGen !== validationGeneration) return;`. Stale completions silently no-op. + +The check goes after every `await` boundary where a newer call could have started — in practice, after the `validateApiKey()` await and after `hasPersistedValidEcoKey()` awaits. + +### Bug C — window-focus trigger is too aggressive + +`onDidChangeWindowState` fires every alt-tab back into VS Code, kicking a fresh `/auth/me`. Combined with Bug B, this is the most common flicker source. + +**Fix:** debounce focus-only triggers. Track `let lastValidationAt = 0` at module scope. In `scheduleKeyIndicatorRefresh`, if `reason === "windowFocused"` and `Date.now() - lastValidationAt < 60_000`, skip the call. All other reasons (manual commands, secret change, scan auth failure) bypass the debounce — explicit user intent or state-changing events override. + +Set `lastValidationAt = Date.now()` at the end of every `updateStatusBar` call (success or failure, after the generation check). + +### Bug D — workspace-folder trigger is noise + +`vscode.workspace.onDidChangeWorkspaceFolders` at `src/extension.ts:251-254` triggers a refresh on multi-root changes. Workspace folder identity has nothing to do with ReCost auth state. + +**Fix:** delete the listener and its subscription registration. + +### What is NOT changing + +- The five status-bar states (`Not Configured`, `Connected`, `Auth Check Failed`, `Invalid Key`, `Unreachable`) stay. +- The persisted-snapshot fallback semantics stay. +- The `secrets.onDidChange` trigger stays. +- Manual-command triggers (openPanel, openKeys, scanWorkspace) stay. + +### Tests + +Unit tests: +- Generation counter: race two `updateStatusBar` calls where the older finishes second; assert final state reflects the newer call. This requires DI for the validation function and the status-bar item; if the wiring gets ugly, drop the unit test and rely on manual EDH gate B. +- Focus debounce: simulate two `windowFocused` triggers within 60s; assert only one `validateApiKey` fetch fires. + +Integration test: +- Wire a stubbed scan response that returns 401; assert that after the catch block runs, the status-bar refresh callback was invoked. + +Manual EDH gates (added to PR test plan, unchecked at PR open): +1. Revoke key in dashboard → run scan → confirm status bar flips to "Invalid Key" (not just sidebar). Tests Bug A. +2. Alt-tab away and back 5x rapidly while connected → status text stays "Connected", no flicker. Tests Bugs B + C. +3. Configure a fresh invalid key that returns 404 from `/auth/me` → status bar shows "Invalid Key", not "Connected". Tests #94. +4. Submit 11 scans within 60s → 11th run shows the rate-limit scanNotification with a wait time. Tests #100. + +--- + +## Verification gates + +Standard: +- `npm run build` clean. +- `npm test` passes (existing + new tests). +- D1 benchmark Δ +0.00pp (no scanner change expected; run as sanity). + +Manual EDH gates: see Section C tests above. + +--- + +## Files touched + +| File | Purpose | +|------|---------| +| `src/api-client.ts` | Remove 404 branch in `validateApiKey`; plumb `retryAfterSeconds` in `apiFetch` | +| `src/extension.ts` | Generation counter, focus debounce, drop workspace-folders trigger, expose `refreshStatusBar` callback, collapse `if (user)` branch | +| `src/webview/scan-publishing-handler.ts` | 429 catch branch with Retry-After; invoke `refreshStatusBar` from auth-failure handler | +| `src/webview-provider.ts` | Wire `refreshStatusBar` through the context interface | +| `src/test/api-client.test.ts` | Update 404 test; add Retry-After tests | +| `src/test/scan-publishing-handler.test.ts` (new) | 429 scanNotification tests | +| `CLAUDE.md` | Rename `validateRcApiKey` → `validateApiKey` in the auth section | + +No new files. No removed files. diff --git a/package.json b/package.json index 1097928..6a6e1c2 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "build:webview": "cd webview && npm run build", "build:dashboard": "cd dashboard && npm run build && rm -rf ../dashboard-dist && cp -r dist ../dashboard-dist", "test": "npm run test:scanner", - "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js", + "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js", "calibrate-detectors": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/waste-calibration.js", "watch:ext": "node esbuild.mjs --watch", "watch:webview": "cd webview && npm run build -- --watch", diff --git a/src/api-client.ts b/src/api-client.ts index 9a4f914..22f72fd 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -6,6 +6,8 @@ interface ApiError { error?: { message?: string }; } +export type ApiClientError = Error & { status: number; retryAfterSeconds?: number }; + async function apiFetch(path: string, init?: RequestInit, rcApiKey?: string): Promise { return apiFetchWith(path, init, rcApiKey, fetch); } @@ -26,8 +28,15 @@ async function apiFetchWith( if (!res.ok) { const body = await res.json().catch(() => ({})) as ApiError; const msg = body?.error?.message ?? `API error ${res.status}`; - const err = new Error(msg) as Error & { status: number }; + const err = new Error(msg) as ApiClientError; err.status = res.status; + if (res.status === 429) { + const header = res.headers.get("Retry-After"); + const parsed = header !== null ? Number.parseInt(header, 10) : NaN; + if (Number.isFinite(parsed) && parsed >= 0) { + err.retryAfterSeconds = parsed; + } + } throw err; } return res.json() as Promise; @@ -129,24 +138,17 @@ export interface AuthMeUser { /** * Validates an API key against GET /auth/me. - * Returns AuthMeUser on success, null for 404 (dev mode — endpoint not yet deployed). + * Returns AuthMeUser on success. * Throws with err.status === 401 for invalid key. + * Throws with err.status === for other HTTP errors (including 404). * Throws without .status for network errors. */ -export async function validateApiKey(key: string): Promise { +export async function validateApiKey(key: string): Promise { if (!key.startsWith("rc-")) { const err = new Error("Invalid ReCost API key — keys must start with rc-") as Error & { status: number }; err.status = 401; throw err; } - try { - const { data } = await apiFetch<{ data: AuthMeUser }>("/auth/me", undefined, key); - return data; - } catch (err: unknown) { - const error = err as Error & { status?: number }; - if (error.status === 404) { - return null; // Dev mode: auth endpoint not deployed, treat key as valid - } - throw err; - } + const { data } = await apiFetch<{ data: AuthMeUser }>("/auth/me", undefined, key); + return data; } diff --git a/src/extension.ts b/src/extension.ts index 6a593fc..85268a0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,8 @@ const DEFAULT_SYNC_INTERVAL_HOURS = 6; const KEY_VALIDATION_STATE_STORAGE_KEY = "recost.keyValidationState"; let activePricingSyncIntervalId: ReturnType | null = null; +let validationGeneration = 0; +let lastValidationAt = 0; function logStatus(output: vscode.OutputChannel, message: string): void { const line = `[${new Date().toISOString()}] ${message}`; @@ -32,55 +34,61 @@ async function updateStatusBar( context: vscode.ExtensionContext, output: vscode.OutputChannel ): Promise { + const myGen = ++validationGeneration; const key = await readStoredSecret(getKeyService("recost"), context.secrets); + if (myGen !== validationGeneration) return false; if (!key) { logStatus(output, "updateStatusBar: no stored ReCost key; setting keyOnline=false"); statusBar.text = "$(key) ReCost: Not Configured"; statusBar.tooltip = "Click to manage your ReCost API keys"; statusBar.color = undefined; await vscode.commands.executeCommand("setContext", "recost.keyOnline", false); + if (myGen === validationGeneration) lastValidationAt = Date.now(); return false; } try { const user = await validateApiKey(key); - if (user) { - logStatus(output, `updateStatusBar: validateApiKey succeeded for ${user.email}; setting keyOnline=true`); - statusBar.text = `$(check) ReCost: ${user.email}`; - statusBar.tooltip = `Connected as ${user.email}`; - } else { - logStatus(output, "updateStatusBar: validateApiKey returned no user but succeeded; setting keyOnline=true"); - statusBar.text = "$(check) ReCost: Connected"; - statusBar.tooltip = "ReCost API key configured"; - } + if (myGen !== validationGeneration) return true; + logStatus(output, `updateStatusBar: validateApiKey succeeded for ${user.email}; setting keyOnline=true`); + statusBar.text = `$(check) ReCost: ${user.email}`; + statusBar.tooltip = `Connected as ${user.email}`; statusBar.color = new vscode.ThemeColor("testing.iconPassed"); await vscode.commands.executeCommand("setContext", "recost.keyOnline", true); + if (myGen === validationGeneration) lastValidationAt = Date.now(); return true; } catch (err: unknown) { + if (myGen !== validationGeneration) return false; const error = err as Error & { status?: number }; if (error.status === 401) { - if (await hasPersistedValidEcoKey(context, output)) { + const hasSnapshot = await hasPersistedValidEcoKey(context, output); + if (myGen !== validationGeneration) return false; + if (hasSnapshot) { logStatus(output, `updateStatusBar: validateApiKey returned 401 (${error.message}) but a persisted valid snapshot still exists; keeping keyOnline=true`); statusBar.text = "$(warning) ReCost: Auth Check Failed"; statusBar.tooltip = "Stored ReCost key was previously validated, but the latest background auth check returned 401."; statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); await vscode.commands.executeCommand("setContext", "recost.keyOnline", true); + if (myGen === validationGeneration) lastValidationAt = Date.now(); return true; } - logStatus(output, `updateStatusBar: validateApiKey returned 401 (${error.message}); setting keyOnline=false`); statusBar.text = "$(warning) ReCost: Invalid Key"; statusBar.tooltip = "ReCost API key is invalid. Click to manage keys."; statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); await vscode.commands.executeCommand("setContext", "recost.keyOnline", false); + if (myGen === validationGeneration) lastValidationAt = Date.now(); return false; } - if (await hasPersistedValidEcoKey(context, output)) { + const hasSnapshot = await hasPersistedValidEcoKey(context, output); + if (myGen !== validationGeneration) return false; + if (hasSnapshot) { logStatus(output, `updateStatusBar: validateApiKey failed transiently (${error.message}); keeping keyOnline=true from persisted valid snapshot`); statusBar.text = "$(check) ReCost: Connected"; statusBar.tooltip = "ReCost key is stored and was previously validated. ReCost is temporarily unreachable."; statusBar.color = new vscode.ThemeColor("testing.iconPassed"); await vscode.commands.executeCommand("setContext", "recost.keyOnline", true); + if (myGen === validationGeneration) lastValidationAt = Date.now(); return true; } else { logStatus(output, `updateStatusBar: validateApiKey failed without trusted snapshot (${error.message}); setting keyOnline=false`); @@ -88,6 +96,7 @@ async function updateStatusBar( statusBar.tooltip = "Cannot reach ReCost. Check your connection."; statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); await vscode.commands.executeCommand("setContext", "recost.keyOnline", false); + if (myGen === validationGeneration) lastValidationAt = Date.now(); return false; } } @@ -122,6 +131,10 @@ function scheduleKeyIndicatorRefresh( output: vscode.OutputChannel, reason: string ): void { + if (reason === "windowFocused" && Date.now() - lastValidationAt < 60_000) { + logStatus(output, `scheduleKeyIndicatorRefresh: debounced reason=${reason}`); + return; + } void (async () => { logStatus(output, `scheduleKeyIndicatorRefresh: begin reason=${reason}`); await updateStatusBar(statusBar, context, output); @@ -149,7 +162,9 @@ export function activate(context: vscode.ExtensionContext) { let includeTestFiles = false; - const provider = new ReCostSidebarProvider(context); + const provider = new ReCostSidebarProvider(context, () => { + scheduleKeyIndicatorRefresh(statusBar, context, statusOutput, "scanAuthFailure"); + }); context.subscriptions.push( vscode.window.registerWebviewViewProvider(ReCostSidebarProvider.viewType, provider, { @@ -247,11 +262,6 @@ export function activate(context: vscode.ExtensionContext) { } }) ); - context.subscriptions.push( - vscode.workspace.onDidChangeWorkspaceFolders(() => { - scheduleKeyIndicatorRefresh(statusBar, context, statusOutput, "workspaceFoldersChanged"); - }) - ); const statusOnlineCommand = vscode.commands.registerCommand("recost.statusOnline", () => {}); const statusLocalCommand = vscode.commands.registerCommand("recost.statusLocal", () => {}); diff --git a/src/test/api-client.test.ts b/src/test/api-client.test.ts index 614de11..0223f4c 100644 --- a/src/test/api-client.test.ts +++ b/src/test/api-client.test.ts @@ -61,7 +61,7 @@ async function runTests() { } // -------- validateApiKey (the /auth/me variant) -------- - // Returns AuthMeUser on 200, null on 404 (dev mode), throws on 401, throws on bad prefix. + // Returns AuthMeUser on 200; throws on 404, 401, network errors, or bad prefix. // 4. validateApiKey rejects keys that don't start with rc- (no fetch made) { @@ -78,12 +78,18 @@ async function runTests() { } } - // 5. validateApiKey returns null on 404 (dev-mode backend, /auth/me not deployed) + // 5. validateApiKey throws with status: 404 when /auth/me returns 404 { const restore = installFetch(() => new Response("", { status: 404 })); try { - const r = await validateApiKey("rc-validlooking"); - assert.equal(r, null); + let caught: (Error & { status?: number }) | null = null; + try { + await validateApiKey("rc-validlooking"); + } catch (err) { + caught = err as Error & { status?: number }; + } + assert.ok(caught, "expected validateApiKey to throw on 404"); + assert.equal(caught!.status, 404); } finally { restore(); } @@ -110,7 +116,79 @@ async function runTests() { ); try { const r = await validateApiKey("rc-good"); - assert.equal((r as { email: string } | null)?.email, "x@y.z"); + assert.equal((r as { email: string }).email, "x@y.z"); + } finally { + restore(); + } + } + + // 8. apiFetch attaches retryAfterSeconds on 429 with numeric Retry-After + { + const restore = installFetch( + () => + new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "Retry-After": "42" }, + }) + ); + try { + let caught: (Error & { status?: number; retryAfterSeconds?: number }) | null = null; + try { + await validateApiKey("rc-good"); + } catch (err) { + caught = err as Error & { status?: number; retryAfterSeconds?: number }; + } + assert.ok(caught, "expected validateApiKey to throw on 429"); + assert.equal(caught!.status, 429); + assert.equal(caught!.retryAfterSeconds, 42); + } finally { + restore(); + } + } + + // 9. apiFetch leaves retryAfterSeconds undefined when Retry-After is absent + { + const restore = installFetch( + () => + new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + }) + ); + try { + let caught: (Error & { status?: number; retryAfterSeconds?: number }) | null = null; + try { + await validateApiKey("rc-good"); + } catch (err) { + caught = err as Error & { status?: number; retryAfterSeconds?: number }; + } + assert.ok(caught, "expected validateApiKey to throw on 429"); + assert.equal(caught!.status, 429); + assert.equal(caught!.retryAfterSeconds, undefined); + } finally { + restore(); + } + } + + // 10. apiFetch leaves retryAfterSeconds undefined when Retry-After is an HTTP-date (RFC 7231) + // We only accept integer seconds; the date form is silently dropped per spec. + { + const restore = installFetch( + () => + new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "Retry-After": "Wed, 21 Oct 2099 07:28:00 GMT" }, + }) + ); + try { + let caught: (Error & { status?: number; retryAfterSeconds?: number }) | null = null; + try { + await validateApiKey("rc-good"); + } catch (err) { + caught = err as Error & { status?: number; retryAfterSeconds?: number }; + } + assert.ok(caught, "expected validateApiKey to throw on 429"); + assert.equal(caught!.status, 429); + assert.equal(caught!.retryAfterSeconds, undefined); } finally { restore(); } diff --git a/src/test/scan-publishing-handler.test.ts b/src/test/scan-publishing-handler.test.ts new file mode 100644 index 0000000..9c2cec0 --- /dev/null +++ b/src/test/scan-publishing-handler.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import Module from "node:module"; + +// Stub `vscode` before any dependency tries to require it. +const originalResolve = (Module as unknown as { + _resolveFilename: (req: string, parent: unknown) => string; +})._resolveFilename; +(Module as unknown as { + _resolveFilename: (req: string, parent: unknown) => string; +})._resolveFilename = function (request: string, parent: unknown) { + if (request === "vscode") return require.resolve("./vscode-stub"); + return originalResolve.call(this, request, parent); +}; + +// Stub the workspace-scanner before scan-publishing-handler is imported, so we +// can drive handleStartScan() without touching the real scanner. +// Returning a real apiCall is required so the handler reaches the submitScan +// branch rather than short-circuiting via the apiCalls.length === 0 path. +const scannerStub = { + scanWorkspace: async () => [ + { + file: "src/app.ts", + line: 1, + method: "POST", + url: "https://api.openai.com/v1/chat/completions", + library: "fetch", + }, + ], + detectLocalWastePatterns: async () => [], + countScopedWorkspaceFiles: async () => 0, + getWorkspaceScanFiles: async () => [], +}; +require.cache[require.resolve("../scanner/workspace-scanner")] = { + id: require.resolve("../scanner/workspace-scanner"), + filename: require.resolve("../scanner/workspace-scanner"), + loaded: true, + exports: scannerStub, +} as unknown as NodeJS.Module; + +// Stub api-client.submitScan to reject with the status we want to test. +let nextScanError: (Error & { status?: number; retryAfterSeconds?: number }) | null = null; +require.cache[require.resolve("../api-client")] = { + id: require.resolve("../api-client"), + filename: require.resolve("../api-client"), + loaded: true, + exports: { + createProject: async () => "proj-stub", + submitScan: async () => { + if (nextScanError) throw nextScanError; + return { scanId: "scan-stub", summary: { totalEndpoints: 0, redundantCalls: 0, n1Suspects: 0, batchOpportunities: 0, cacheOpportunities: 0 } }; + }, + getAllEndpoints: async () => [], + getAllSuggestions: async () => [], + }, +} as unknown as NodeJS.Module; + +import { ScanPublishingHandler, type ScanPublishingHandlerContext } from "../webview/scan-publishing-handler"; +import type { HostMessage } from "../messages"; + +function makeCtx(posted: HostMessage[]): ScanPublishingHandlerContext { + const noop = async () => {}; + return { + postMessage: (m) => { posted.push(m); }, + context: { secrets: { get: async () => undefined }, globalState: { get: () => undefined, update: noop }, workspaceState: { get: () => undefined, update: noop } } as never, + setLastEndpoints: () => {}, + setLastSuggestions: () => {}, + setLastSummary: () => {}, + setLastApiCalls: () => {}, + setLastFindings: () => {}, + setProjectId: () => {}, + getProjectId: () => null, + getManualProjectId: () => null, + getRcApiKey: async () => "rc-good", + resolveScanProjectTarget: async () => ({ projectId: "proj-stub", source: "auto" }), + getWorkspaceName: () => "ws", + openKeys: () => {}, + setRecostValidationState: noop, + clearRecostValidationState: noop, + sendRecostKeyStatusUpdate: noop, + refreshStatusBar: () => {}, + resetChatHistory: () => {}, + exportDebugScanResults: noop, + pruneSavedScenariosAgainst: noop, + }; +} + +async function runTests() { + // 1. 429 with Retry-After: 42 surfaces a scanNotification with "42 seconds" and publishes local results + { + const posted: HostMessage[] = []; + const err = new Error("rate limited") as Error & { status: number; retryAfterSeconds: number }; + err.status = 429; + err.retryAfterSeconds = 42; + nextScanError = err; + const handler = new ScanPublishingHandler(makeCtx(posted)); + await handler.handleStartScan(); + const notification = posted.find((m) => m.type === "scanNotification") as { type: "scanNotification"; message: string } | undefined; + assert.ok(notification, "expected a scanNotification message"); + assert.match(notification!.message, /42 seconds/); + assert.match(notification!.message, /local results/i); + } + + // 2. 429 without Retry-After surfaces the generic "in a moment" message + { + const posted: HostMessage[] = []; + const err = new Error("rate limited") as Error & { status: number }; + err.status = 429; + nextScanError = err; + const handler = new ScanPublishingHandler(makeCtx(posted)); + await handler.handleStartScan(); + const notification = posted.find((m) => m.type === "scanNotification") as { type: "scanNotification"; message: string } | undefined; + assert.ok(notification, "expected a scanNotification message"); + assert.match(notification!.message, /in a moment/); + } + + // 3. 429 with retryAfterSeconds: 1 produces "1 second" (singular) + { + const posted: HostMessage[] = []; + const err = new Error("rate limited") as Error & { status: number; retryAfterSeconds: number }; + err.status = 429; + err.retryAfterSeconds = 1; + nextScanError = err; + const handler = new ScanPublishingHandler(makeCtx(posted)); + await handler.handleStartScan(); + const notification = posted.find((m) => m.type === "scanNotification") as { type: "scanNotification"; message: string } | undefined; + assert.ok(notification, "expected a scanNotification message"); + assert.match(notification!.message, /1 second\b/); + } + + // 4. 401 calls refreshStatusBar() exactly once after sendRecostKeyStatusUpdate + { + const posted: HostMessage[] = []; + let refreshCalls = 0; + let sentKeyUpdate = false; + let refreshedAfterUpdate = false; + const err = new Error("invalid auth") as Error & { status: number }; + err.status = 401; + nextScanError = err; + const ctx: ScanPublishingHandlerContext = { + ...makeCtx(posted), + sendRecostKeyStatusUpdate: async () => { sentKeyUpdate = true; }, + refreshStatusBar: () => { + refreshCalls++; + if (sentKeyUpdate) refreshedAfterUpdate = true; + }, + }; + const handler = new ScanPublishingHandler(ctx); + await handler.handleStartScan(); + assert.equal(refreshCalls, 1); + assert.equal(refreshedAfterUpdate, true, "refreshStatusBar must be called after sendRecostKeyStatusUpdate"); + } + + console.log("PASS scan-publishing-handler"); +} + +runTests().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/webview-provider.ts b/src/webview-provider.ts index 49e47be..4e9bfa5 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -152,6 +152,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private readonly keyManagementHandler: KeyManagementHandler; private readonly simulationHandler: SimulationHandler; private readonly scanPublishingHandler: ScanPublishingHandler; + private readonly refreshStatusBar: () => void; private readonly outputChannel: vscode.OutputChannel; private readonly projectIdCheckingState = new Set(); @@ -195,8 +196,9 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } } - constructor(context: vscode.ExtensionContext) { + constructor(context: vscode.ExtensionContext, refreshStatusBar: () => void) { this.context = context; + this.refreshStatusBar = refreshStatusBar; this.outputChannel = vscode.window.createOutputChannel("ReCost AI Review"); this.context.subscriptions.push(this.outputChannel); this.simulationHandler = new SimulationHandler({ @@ -251,6 +253,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { setRecostValidationState: (snapshot) => this.setValidationState("recost", snapshot), clearRecostValidationState: () => this.clearValidationState("recost"), sendRecostKeyStatusUpdate: () => this.sendKeyStatusUpdate("recost", "recost"), + refreshStatusBar: () => { this.refreshStatusBar(); }, resetChatHistory: () => this.chatHandler.resetHistory(), exportDebugScanResults: (payload) => this.exportDebugScanResults(payload), pruneSavedScenariosAgainst: (endpoints) => this.simulationHandler.pruneAgainst(endpoints), diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index cfaec97..2bb5d40 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -5,7 +5,7 @@ import { countScopedWorkspaceFiles, getWorkspaceScanFiles, } from "../scanner/workspace-scanner"; -import { createProject, submitScan, getAllEndpoints, getAllSuggestions } from "../api-client"; +import { createProject, submitScan, getAllEndpoints, getAllSuggestions, type ApiClientError } from "../api-client"; import type { HostMessage, KeyServiceId } from "../messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "../analysis/types"; import { classifyEndpointScope, detectEndpointProvider } from "../scanner/endpoint-classification"; @@ -60,6 +60,7 @@ export interface ScanPublishingHandlerContext { setRecostValidationState(snapshot: PersistedKeyValidationSnapshot): Promise; clearRecostValidationState(): Promise; sendRecostKeyStatusUpdate(): Promise; + refreshStatusBar(): void; resetChatHistory(): void; exportDebugScanResults(payload: ExportDebugPayload): Promise; pruneSavedScenariosAgainst(endpoints: EndpointRecord[]): Promise; @@ -777,7 +778,22 @@ export class ScanPublishingHandler { }); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Remote analysis failed"; - const status = (err as { status?: number }).status; + const apiErr = err as Partial; + const status = apiErr.status; + + if (status === 429) { + const retryAfter = apiErr.retryAfterSeconds; + const waitText = retryAfter !== undefined + ? `Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.` + : "Try again in a moment."; + this.ctx.postMessage({ + type: "scanNotification", + message: `ReCost scan rate limit reached. ${waitText} Showing local results.`, + }); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + return; + } + const authLikeFailure = status === 401 || (status === 403 && /invalid|unauthori[sz]ed|forbidden|auth/i.test(message)); @@ -795,6 +811,7 @@ export class ScanPublishingHandler { await this.ctx.clearRecostValidationState(); } await this.ctx.sendRecostKeyStatusUpdate(); + this.ctx.refreshStatusBar(); this.ctx.openKeys("recost"); } publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); diff --git a/webview/tsconfig.tsbuildinfo b/webview/tsconfig.tsbuildinfo deleted file mode 100644 index 9d68cf3..0000000 --- a/webview/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/vscode.ts","./src/components/ChatPage.tsx","./src/components/KeysPage.tsx","./src/components/LandingPage.tsx","./src/components/LeafIcon.tsx","./src/components/Markdown.tsx","./src/components/ResultsPage.tsx","./src/components/ScanningPage.tsx","./src/components/SimulatePage.tsx"],"version":"5.9.3"} \ No newline at end of file