From 7de27a06f4eca44e5321651bc4aac240ec563177 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Wed, 20 May 2026 14:10:07 -0500 Subject: [PATCH 1/2] chore(docs): regenerate hindsight-docs skill mirror Pre-commit hook auto-sync caught drift between hindsight-docs/ sources and the skills/hindsight-docs/ mirror. No content authored here. --- skills/hindsight-docs/references/developer/models.md | 1 + skills/hindsight-docs/references/faq.md | 1 + 2 files changed, 2 insertions(+) diff --git a/skills/hindsight-docs/references/developer/models.md b/skills/hindsight-docs/references/developer/models.md index b50e625a4..51278726a 100644 --- a/skills/hindsight-docs/references/developer/models.md +++ b/skills/hindsight-docs/references/developer/models.md @@ -30,6 +30,7 @@ Used for fact extraction, entity resolution, mental model consolidation, and ans - MiniMax - DeepSeek - z.ai +- opencode-go - Volcano Engine - OpenRouter - OpenAI Codex diff --git a/skills/hindsight-docs/references/faq.md b/skills/hindsight-docs/references/faq.md index 8b8b96a62..f4f8f2aca 100644 --- a/skills/hindsight-docs/references/faq.md +++ b/skills/hindsight-docs/references/faq.md @@ -76,6 +76,7 @@ Browse all supported integrations in the Integrations Hub. - MiniMax - DeepSeek - z.ai +- opencode-go - Volcano Engine - OpenRouter - OpenAI Codex From 47b05cbd8829f654d5a4432a5e32ac154f049762 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Wed, 20 May 2026 14:10:31 -0500 Subject: [PATCH 2/2] fix(control-plane): surface upstream errors via respondWithSdk helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1677. The SDK (@hey-api/client-fetch shape) returns `{data, error, response}` and does not throw on non-2xx upstream responses. Route handlers were doing `NextResponse.json(response.data, {status: 200})` without checking `response.error` first. When the upstream API 5xx'd, `response.data` was `undefined`, and Node's spec'd `Response.json(undefined)` threw `TypeError: Value is not JSON serializable`. The catch block logged that TypeError as if it were the failure, masking the real upstream error and hard-coding the response status to 500. Introduce `src/lib/sdk-response.ts::respondWithSdk(result, label, status?)` that: - Detects `result.error !== undefined || result.data === undefined` - Logs the upstream HTTP status + upstream error detail - Returns a NextResponse with the upstream status code (502 fallback when the SDK had no Response object — i.e. network-level failure) - Surfaces the upstream detail in the body as `{error, upstream: {status, detail}}` so the dashboard can show a useful message - On success, serializes `result.data` with the requested status (default 200; pass 201 for create endpoints) Refactor 17 SDK-backed route files to use the helper. Routes that parse a request body keep a minimal try/catch around `await request.json()` and return 400 on malformed JSON (a small UX improvement over the prior 500). Routes that use raw `fetch()` (documents PATCH, operations retry POST) and the observations route (which does post-fetch transformation of `response.data.items`) are left untouched — they don't exhibit the bug. Add vitest + 12 durable tests covering the helper (success path with custom status, failure pass-through for 500/503/429, body shape includes `upstream.detail`, regression assertion that NO TypeError escapes when data is undefined, default-502 for network-level failures with no Response object). Wire `npm test --workspace=hindsight-control-plane` into the existing `build-control-plane` and `build-hindsight-all` CI jobs so the helper stays load-bearing. Browser UX is unchanged on the happy path. On failures, operators now see the real upstream status code and error body in both logs and the response. --- .github/workflows/test.yml | 6 + .../engine/providers/openai_compatible_llm.py | 22 +- hindsight-control-plane/package.json | 5 +- .../app/api/banks/[bankId]/config/route.ts | 81 +++---- .../api/banks/[bankId]/consolidate/route.ts | 30 +-- .../[bankId]/consolidation-recover/route.ts | 30 +-- .../operations/[operationId]/route.ts | 42 ++-- .../src/app/api/banks/[bankId]/route.ts | 118 ++++------ .../src/app/api/banks/route.ts | 51 ++-- .../src/app/api/chunks/[chunkId]/route.ts | 22 +- .../app/api/documents/[documentId]/route.ts | 57 ++--- .../src/app/api/documents/route.ts | 33 ++- .../entities/[entityId]/regenerate/route.ts | 40 ++-- .../src/app/api/entities/[entityId]/route.ts | 43 ++-- .../src/app/api/entities/graph/route.ts | 45 ++-- .../src/app/api/entities/route.ts | 37 ++- .../app/api/memories/retain_async/route.ts | 34 +-- .../src/app/api/operations/[agentId]/route.ts | 60 ++--- .../src/app/api/profile/[bankId]/route.ts | 42 ++-- .../src/app/api/reflect/route.ts | 94 ++++---- .../src/app/api/stats/[agentId]/route.ts | 20 +- .../src/lib/sdk-response.test.ts | 154 ++++++++++++ .../src/lib/sdk-response.ts | 64 +++++ hindsight-control-plane/vitest.config.ts | 15 ++ package-lock.json | 219 +++++++++++++++++- 25 files changed, 825 insertions(+), 539 deletions(-) create mode 100644 hindsight-control-plane/src/lib/sdk-response.test.ts create mode 100644 hindsight-control-plane/src/lib/sdk-response.ts create mode 100644 hindsight-control-plane/vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 055a0b95b..f44ab14c7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -734,6 +734,9 @@ jobs: rm -rf node_modules/lightningcss node_modules/@tailwindcss npm install lightningcss @tailwindcss/postcss @tailwindcss/node + - name: Test Control Plane + run: npm test --workspace=hindsight-control-plane + - name: Build Control Plane run: npm run build --workspace=hindsight-control-plane @@ -3200,6 +3203,9 @@ jobs: rm -rf node_modules/lightningcss node_modules/@tailwindcss npm install lightningcss @tailwindcss/postcss @tailwindcss/node + - name: Test Control Plane + run: npm test --workspace=hindsight-control-plane + - name: Build Control Plane run: npm run build --workspace=hindsight-control-plane diff --git a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py index 9bb6fdba0..0f02949d5 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py @@ -306,15 +306,19 @@ def __init__( self.api_key = "local" # Validate API key for cloud providers - if self.provider in ( - "openai", - "groq", - "minimax", - "deepseek", - "openrouter", - "zai", - "opencode-go", - ) and not self.api_key: + if ( + self.provider + in ( + "openai", + "groq", + "minimax", + "deepseek", + "openrouter", + "zai", + "opencode-go", + ) + and not self.api_key + ): raise ValueError(f"API key is required for {self.provider}") # Service tier configuration (from config, not env vars) diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index 8755d8627..31e8a635b 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -16,6 +16,8 @@ "build:standalone": "rm -rf standalone && SERVER_JS=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1) && test -n \"$SERVER_JS\" || (echo 'Error: server.js not found in .next/standalone - standalone build failed' && exit 1) && STANDALONE_ROOT=$(dirname \"$SERVER_JS\") && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && (cp -r public/* standalone/public/ 2>/dev/null || true)", "start": "next start", "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", "prepublishOnly": "npm run build" }, "keywords": [ @@ -81,6 +83,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "prettier": "^3.7.4", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.50.0", + "vitest": "^4.1.7" } } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts index 20096dfdd..c601382e1 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts @@ -1,77 +1,48 @@ import { NextRequest, NextResponse } from "next/server"; import { lowLevelClient, sdk } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { - try { - const { bankId } = await params; - - const response = await sdk.getBankConfig({ - client: lowLevelClient, - path: { bank_id: bankId }, - }); - - if (!response.data) { - console.error("[Bank Config API] No data in response", { response, error: response.error }); - throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching bank config:", error); - return NextResponse.json({ error: "Failed to fetch bank config" }, { status: 500 }); - } + const { bankId } = await params; + const response = await sdk.getBankConfig({ + client: lowLevelClient, + path: { bank_id: bankId }, + }); + return respondWithSdk(response, "Failed to fetch bank config"); } export async function PATCH( request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { + const { bankId } = await params; + let body; try { - const { bankId } = await params; - const body = await request.json(); - const { updates } = body; - - const response = await sdk.updateBankConfig({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: { updates }, - }); - - if (!response.data) { - console.error("[Bank Config API] No data in response", { response, error: response.error }); - throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error updating bank config:", error); - return NextResponse.json({ error: "Failed to update bank config" }, { status: 500 }); + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + const { updates } = body; + + const response = await sdk.updateBankConfig({ + client: lowLevelClient, + path: { bank_id: bankId }, + body: { updates }, + }); + return respondWithSdk(response, "Failed to update bank config"); } export async function DELETE( request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { - try { - const { bankId } = await params; - - const response = await sdk.resetBankConfig({ - client: lowLevelClient, - path: { bank_id: bankId }, - }); - - if (!response.data) { - console.error("[Bank Config API] No data in response", { response, error: response.error }); - throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error resetting bank config:", error); - return NextResponse.json({ error: "Failed to reset bank config" }, { status: 500 }); - } + const { bankId } = await params; + const response = await sdk.resetBankConfig({ + client: lowLevelClient, + path: { bank_id: bankId }, + }); + return respondWithSdk(response, "Failed to reset bank config"); } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts index 2d6c9e33a..46743ff8a 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts @@ -1,27 +1,17 @@ import { NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { - try { - const { bankId } = await params; + const { bankId } = await params; - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.triggerConsolidation({ - client: lowLevelClient, - path: { bank_id: bankId }, - }); - - if (response.error) { - console.error("API error triggering consolidation:", response.error); - return NextResponse.json({ error: "Failed to trigger consolidation" }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error triggering consolidation:", error); - return NextResponse.json({ error: "Failed to trigger consolidation" }, { status: 500 }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.triggerConsolidation({ + client: lowLevelClient, + path: { bank_id: bankId }, + }); + return respondWithSdk(response, "Failed to trigger consolidation"); } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts index 42eabf04a..b8fa9e868 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts @@ -1,27 +1,17 @@ import { NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { - try { - const { bankId } = await params; + const { bankId } = await params; - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.recoverConsolidation({ - client: lowLevelClient, - path: { bank_id: bankId }, - }); - - if (response.error) { - console.error("API error recovering consolidation:", response.error); - return NextResponse.json({ error: "Failed to recover consolidation" }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error recovering consolidation:", error); - return NextResponse.json({ error: "Failed to recover consolidation" }, { status: 500 }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.recoverConsolidation({ + client: lowLevelClient, + path: { bank_id: bankId }, + }); + return respondWithSdk(response, "Failed to recover consolidation"); } diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts index dbfa16d75..c4285288d 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts @@ -1,40 +1,30 @@ import { NextResponse } from "next/server"; import { sdk, lowLevelClient, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: Request, { params }: { params: Promise<{ bankId: string; operationId: string }> } ) { - try { - const { bankId, operationId } = await params; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - if (!operationId) { - return NextResponse.json({ error: "operation_id is required" }, { status: 400 }); - } + const { bankId, operationId } = await params; - const url = new URL(request.url); - const includePayload = url.searchParams.get("include_payload") === "true"; + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } - const response = await sdk.getOperationStatus({ - client: lowLevelClient, - path: { bank_id: bankId, operation_id: operationId }, - query: includePayload ? { include_payload: true } : undefined, - }); + if (!operationId) { + return NextResponse.json({ error: "operation_id is required" }, { status: 400 }); + } - if (response.error) { - console.error("API error getting operation status:", response.error); - return NextResponse.json({ error: "Failed to get operation status" }, { status: 500 }); - } + const url = new URL(request.url); + const includePayload = url.searchParams.get("include_payload") === "true"; - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error getting operation status:", error); - return NextResponse.json({ error: "Failed to get operation status" }, { status: 500 }); - } + const response = await sdk.getOperationStatus({ + client: lowLevelClient, + path: { bank_id: bankId, operation_id: operationId }, + query: includePayload ? { include_payload: true } : undefined, + }); + return respondWithSdk(response, "Failed to get operation status"); } export async function POST( diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts index 001716838..baa96c447 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts @@ -1,92 +1,70 @@ import { NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function PUT(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + const { bankId } = await params; + let body; try { - const { bankId } = await params; - const body = await request.json(); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: { - name: body.name, - mission: body.mission, - disposition: body.disposition, - }, - }); - - if (response.error) { - console.error("API error updating bank:", response.error); - return NextResponse.json({ error: "Failed to update bank" }, { status: 500 }); - } + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error updating bank:", error); - return NextResponse.json({ error: "Failed to update bank" }, { status: 500 }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.createOrUpdateBank({ + client: lowLevelClient, + path: { bank_id: bankId }, + body: { + name: body.name, + mission: body.mission, + disposition: body.disposition, + }, + }); + return respondWithSdk(response, "Failed to update bank"); } export async function PATCH(request: Request, { params }: { params: Promise<{ bankId: string }> }) { + const { bankId } = await params; + let body; try { - const { bankId } = await params; - const body = await request.json(); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.updateBank({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: { - name: body.name, - mission: body.mission, - disposition: body.disposition, - }, - }); - - if (response.error) { - console.error("API error patching bank:", response.error); - return NextResponse.json({ error: "Failed to update bank" }, { status: 500 }); - } + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error patching bank:", error); - return NextResponse.json({ error: "Failed to update bank" }, { status: 500 }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.updateBank({ + client: lowLevelClient, + path: { bank_id: bankId }, + body: { + name: body.name, + mission: body.mission, + disposition: body.disposition, + }, + }); + return respondWithSdk(response, "Failed to update bank"); } export async function DELETE( request: Request, { params }: { params: Promise<{ bankId: string }> } ) { - try { - const { bankId } = await params; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } + const { bankId } = await params; - const response = await sdk.deleteBank({ - client: lowLevelClient, - path: { bank_id: bankId }, - }); - - if (response.error) { - console.error("API error deleting bank:", response.error); - return NextResponse.json({ error: "Failed to delete bank" }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error deleting bank:", error); - return NextResponse.json({ error: "Failed to delete bank" }, { status: 500 }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.deleteBank({ + client: lowLevelClient, + path: { bank_id: bankId }, + }); + return respondWithSdk(response, "Failed to delete bank"); } diff --git a/hindsight-control-plane/src/app/api/banks/route.ts b/hindsight-control-plane/src/app/api/banks/route.ts index 38d904d5d..4ff5be36a 100644 --- a/hindsight-control-plane/src/app/api/banks/route.ts +++ b/hindsight-control-plane/src/app/api/banks/route.ts @@ -1,42 +1,31 @@ import { NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; -export async function GET() { - try { - const response = await sdk.listBanks({ client: lowLevelClient }); - - // Check if the response has an error or no data - if (response.error || !response.data) { - console.error("API error:", response.error); - return NextResponse.json({ error: "Failed to fetch banks from API" }, { status: 500 }); - } +const HTTP_CREATED = 201; - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching banks:", error); - return NextResponse.json({ error: "Failed to fetch banks" }, { status: 500 }); - } +export async function GET() { + const response = await sdk.listBanks({ client: lowLevelClient }); + return respondWithSdk(response, "Failed to fetch banks"); } export async function POST(request: Request) { + let body; try { - const body = await request.json(); - const { bank_id } = body; - - if (!bank_id) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, - path: { bank_id }, - body: {}, - }); + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const { bank_id } = body; - const serializedData = JSON.parse(JSON.stringify(response.data)); - return NextResponse.json(serializedData, { status: 201 }); - } catch (error) { - console.error("Error creating bank:", error); - return NextResponse.json({ error: "Failed to create bank" }, { status: 500 }); + if (!bank_id) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.createOrUpdateBank({ + client: lowLevelClient, + path: { bank_id }, + body: {}, + }); + return respondWithSdk(response, "Failed to create bank", HTTP_CREATED); } diff --git a/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts b/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts index 592625d64..0b76f74cd 100644 --- a/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts +++ b/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts @@ -1,21 +1,15 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: NextRequest, { params }: { params: Promise<{ chunkId: string }> } ) { - try { - const { chunkId } = await params; - - const response = await sdk.getChunk({ - client: lowLevelClient, - path: { chunk_id: chunkId }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching chunk:", error); - return NextResponse.json({ error: "Failed to fetch chunk" }, { status: 500 }); - } + const { chunkId } = await params; + const response = await sdk.getChunk({ + client: lowLevelClient, + path: { chunk_id: chunkId }, + }); + return respondWithSdk(response, "Failed to fetch chunk"); } diff --git a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts index 8d091a3d4..3fb75808f 100644 --- a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts +++ b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts @@ -1,29 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: NextRequest, { params }: { params: Promise<{ documentId: string }> } ) { - try { - const { documentId } = await params; - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); + const { documentId } = await params; + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.getDocument({ - client: lowLevelClient, - path: { bank_id: bankId, document_id: documentId }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching document:", error); - return NextResponse.json({ error: "Failed to fetch document" }, { status: 500 }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.getDocument({ + client: lowLevelClient, + path: { bank_id: bankId, document_id: documentId }, + }); + return respondWithSdk(response, "Failed to fetch document"); } export async function PATCH( @@ -66,23 +61,17 @@ export async function DELETE( request: NextRequest, { params }: { params: Promise<{ documentId: string }> } ) { - try { - const { documentId } = await params; - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); + const { documentId } = await params; + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.deleteDocument({ - client: lowLevelClient, - path: { bank_id: bankId, document_id: documentId }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error deleting document:", error); - return NextResponse.json({ error: "Failed to delete document" }, { status: 500 }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + + const response = await sdk.deleteDocument({ + client: lowLevelClient, + path: { bank_id: bankId, document_id: documentId }, + }); + return respondWithSdk(response, "Failed to delete document"); } diff --git a/hindsight-control-plane/src/app/api/documents/route.ts b/hindsight-control-plane/src/app/api/documents/route.ts index 3f7481ba6..c266b18bc 100644 --- a/hindsight-control-plane/src/app/api/documents/route.ts +++ b/hindsight-control-plane/src/app/api/documents/route.ts @@ -1,27 +1,22 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; - const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } - const response = await sdk.listDocuments({ - client: lowLevelClient, - path: { bank_id: bankId }, - query: { limit, offset }, - }); + const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching documents:", error); - return NextResponse.json({ error: "Failed to fetch documents" }, { status: 500 }); - } + const response = await sdk.listDocuments({ + client: lowLevelClient, + path: { bank_id: bankId }, + query: { limit, offset }, + }); + return respondWithSdk(response, "Failed to fetch documents"); } diff --git a/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts b/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts index 134b1ab2f..1ff173550 100644 --- a/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts +++ b/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts @@ -1,35 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function POST( request: NextRequest, { params }: { params: Promise<{ entityId: string }> } ) { - try { - const { entityId } = await params; - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); + const { entityId } = await params; + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const decodedEntityId = decodeURIComponent(entityId); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } - const response = await sdk.regenerateEntityObservations({ - client: lowLevelClient, - path: { - bank_id: bankId, - entity_id: decodedEntityId, - }, - }); + const decodedEntityId = decodeURIComponent(entityId); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error regenerating entity observations:", error); - return NextResponse.json( - { error: "Failed to regenerate entity observations" }, - { status: 500 } - ); - } + const response = await sdk.regenerateEntityObservations({ + client: lowLevelClient, + path: { + bank_id: bankId, + entity_id: decodedEntityId, + }, + }); + return respondWithSdk(response, "Failed to regenerate entity observations"); } diff --git a/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts b/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts index c674fafad..b392fa1fa 100644 --- a/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts +++ b/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts @@ -1,37 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: NextRequest, { params }: { params: Promise<{ entityId: string }> } ) { - try { - const { entityId } = await params; - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); + const { entityId } = await params; + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - // Decode URL-encoded entityId in case it contains special chars - const decodedEntityId = decodeURIComponent(entityId); - - const response = await sdk.getEntity({ - client: lowLevelClient, - path: { - bank_id: bankId, - entity_id: decodedEntityId, - }, - }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } - if (response.error) { - return NextResponse.json({ error: response.error }, { status: 500 }); - } + // Decode URL-encoded entityId in case it contains special chars + const decodedEntityId = decodeURIComponent(entityId); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error getting entity:", error); - return NextResponse.json({ error: "Failed to get entity" }, { status: 500 }); - } + const response = await sdk.getEntity({ + client: lowLevelClient, + path: { + bank_id: bankId, + entity_id: decodedEntityId, + }, + }); + return respondWithSdk(response, "Failed to get entity"); } diff --git a/hindsight-control-plane/src/app/api/entities/graph/route.ts b/hindsight-control-plane/src/app/api/entities/graph/route.ts index cb6dd9d5b..9d88502a3 100644 --- a/hindsight-control-plane/src/app/api/entities/graph/route.ts +++ b/hindsight-control-plane/src/app/api/entities/graph/route.ts @@ -1,37 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; - const minCount = searchParams.get("min_count") - ? Number(searchParams.get("min_count")) - : undefined; - - const response = await sdk.getEntityGraph({ - client: lowLevelClient, - path: { bank_id: bankId }, - query: { limit, min_count: minCount }, - }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } - if (response.error || !response.data) { - console.error("Entity graph API error:", response.error); - return NextResponse.json( - { error: response.error || "Failed to fetch entity graph" }, - { status: 500 } - ); - } + const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; + const minCount = searchParams.get("min_count") + ? Number(searchParams.get("min_count")) + : undefined; - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching entity graph:", error); - return NextResponse.json({ error: "Failed to fetch entity graph" }, { status: 500 }); - } + const response = await sdk.getEntityGraph({ + client: lowLevelClient, + path: { bank_id: bankId }, + query: { limit, min_count: minCount }, + }); + return respondWithSdk(response, "Failed to fetch entity graph"); } diff --git a/hindsight-control-plane/src/app/api/entities/route.ts b/hindsight-control-plane/src/app/api/entities/route.ts index a54628e4f..be4a3be4a 100644 --- a/hindsight-control-plane/src/app/api/entities/route.ts +++ b/hindsight-control-plane/src/app/api/entities/route.ts @@ -1,31 +1,22 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; - const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; - - const response = await sdk.listEntities({ - client: lowLevelClient, - path: { bank_id: bankId }, - query: { limit, offset }, - }); + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } - if (response.error) { - return NextResponse.json({ error: response.error }, { status: 500 }); - } + const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; + const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error listing entities:", error); - return NextResponse.json({ error: "Failed to list entities" }, { status: 500 }); - } + const response = await sdk.listEntities({ + client: lowLevelClient, + path: { bank_id: bankId }, + query: { limit, offset }, + }); + return respondWithSdk(response, "Failed to list entities"); } diff --git a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts b/hindsight-control-plane/src/app/api/memories/retain_async/route.ts index ec0d5721f..c15efa39c 100644 --- a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts +++ b/hindsight-control-plane/src/app/api/memories/retain_async/route.ts @@ -1,26 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: NextRequest) { + let body; try { - const body = await request.json(); - const bankId = body.bank_id || body.agent_id; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const bankId = body.bank_id || body.agent_id; - const { items } = body; + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } - const response = await sdk.retainMemories({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: { items, async: true }, - }); + const { items } = body; - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error batch retain async:", error); - return NextResponse.json({ error: "Failed to batch retain async" }, { status: 500 }); - } + const response = await sdk.retainMemories({ + client: lowLevelClient, + path: { bank_id: bankId }, + body: { items, async: true }, + }); + return respondWithSdk(response, "Failed to batch retain async"); } diff --git a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts index 9552c80b3..d1a4e3376 100644 --- a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts @@ -1,52 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { - try { - const { agentId } = await params; - const searchParams = request.nextUrl.searchParams; - const status = searchParams.get("status") || undefined; - const type = searchParams.get("type") || undefined; - const limit = searchParams.get("limit") ? parseInt(searchParams.get("limit")!) : undefined; - const offset = searchParams.get("offset") ? parseInt(searchParams.get("offset")!) : undefined; - const excludeParents = searchParams.get("exclude_parents") === "true" ? true : undefined; + const { agentId } = await params; + const searchParams = request.nextUrl.searchParams; + const status = searchParams.get("status") || undefined; + const type = searchParams.get("type") || undefined; + const limit = searchParams.get("limit") ? parseInt(searchParams.get("limit")!) : undefined; + const offset = searchParams.get("offset") ? parseInt(searchParams.get("offset")!) : undefined; + const excludeParents = searchParams.get("exclude_parents") === "true" ? true : undefined; - const response = await sdk.listOperations({ - client: lowLevelClient, - path: { bank_id: agentId }, - query: { status, type, limit, offset, exclude_parents: excludeParents }, - }); - return NextResponse.json(response.data || {}, { status: 200 }); - } catch (error) { - console.error("Error fetching operations:", error); - return NextResponse.json({ error: "Failed to fetch operations" }, { status: 500 }); - } + const response = await sdk.listOperations({ + client: lowLevelClient, + path: { bank_id: agentId }, + query: { status, type, limit, offset, exclude_parents: excludeParents }, + }); + return respondWithSdk(response, "Failed to fetch operations"); } export async function DELETE( request: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { - try { - const { agentId } = await params; - const searchParams = request.nextUrl.searchParams; - const operationId = searchParams.get("operation_id"); - - if (!operationId) { - return NextResponse.json({ error: "operation_id is required" }, { status: 400 }); - } + const { agentId } = await params; + const searchParams = request.nextUrl.searchParams; + const operationId = searchParams.get("operation_id"); - const response = await sdk.cancelOperation({ - client: lowLevelClient, - path: { bank_id: agentId, operation_id: operationId }, - }); - - return NextResponse.json(response.data || {}, { status: 200 }); - } catch (error) { - console.error("Error canceling operation:", error); - return NextResponse.json({ error: "Failed to cancel operation" }, { status: 500 }); + if (!operationId) { + return NextResponse.json({ error: "operation_id is required" }, { status: 400 }); } + + const response = await sdk.cancelOperation({ + client: lowLevelClient, + path: { bank_id: agentId, operation_id: operationId }, + }); + return respondWithSdk(response, "Failed to cancel operation"); } diff --git a/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts b/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts index 583e56950..2650e3101 100644 --- a/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts +++ b/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts @@ -1,39 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { - try { - const { bankId } = await params; - const response = await sdk.getBankProfile({ - client: lowLevelClient, - path: { bank_id: bankId }, - }); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching bank profile:", error); - return NextResponse.json({ error: "Failed to fetch bank profile" }, { status: 500 }); - } + const { bankId } = await params; + const response = await sdk.getBankProfile({ + client: lowLevelClient, + path: { bank_id: bankId }, + }); + return respondWithSdk(response, "Failed to fetch bank profile"); } export async function PUT( request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { + const { bankId } = await params; + let body; try { - const { bankId } = await params; - const body = await request.json(); - - const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: body, - }); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error updating bank profile:", error); - return NextResponse.json({ error: "Failed to update bank profile" }, { status: 500 }); + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + + const response = await sdk.createOrUpdateBank({ + client: lowLevelClient, + path: { bank_id: bankId }, + body: body, + }); + return respondWithSdk(response, "Failed to update bank profile"); } diff --git a/hindsight-control-plane/src/app/api/reflect/route.ts b/hindsight-control-plane/src/app/api/reflect/route.ts index eaf7197b5..f666f396e 100644 --- a/hindsight-control-plane/src/app/api/reflect/route.ts +++ b/hindsight-control-plane/src/app/api/reflect/route.ts @@ -1,56 +1,56 @@ import { NextRequest, NextResponse } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: NextRequest) { + let body; try { - const body = await request.json(); - const bankId = body.bank_id || body.agent_id || "default"; - const { - query, - budget, - thinking_budget, - include_facts, - include_tool_calls, - tags, - tags_match, - max_tokens, - fact_types, - exclude_mental_models, - exclude_mental_model_ids, - } = body; - - const requestBody: any = { - query, - budget: budget || (thinking_budget ? "mid" : "low"), - tags, - tags_match, - max_tokens: max_tokens || undefined, - fact_types: fact_types || undefined, - exclude_mental_models: exclude_mental_models || undefined, - exclude_mental_model_ids: exclude_mental_model_ids || undefined, - }; - - // Add include options if specified - const includeOptions: any = {}; - if (include_facts) { - includeOptions.facts = {}; - } - if (include_tool_calls) { - includeOptions.tool_calls = {}; - } - if (Object.keys(includeOptions).length > 0) { - requestBody.include = includeOptions; - } + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const bankId = body.bank_id || body.agent_id || "default"; + const { + query, + budget, + thinking_budget, + include_facts, + include_tool_calls, + tags, + tags_match, + max_tokens, + fact_types, + exclude_mental_models, + exclude_mental_model_ids, + } = body; - const response = await sdk.reflect({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: requestBody, - }); + const requestBody: any = { + query, + budget: budget || (thinking_budget ? "mid" : "low"), + tags, + tags_match, + max_tokens: max_tokens || undefined, + fact_types: fact_types || undefined, + exclude_mental_models: exclude_mental_models || undefined, + exclude_mental_model_ids: exclude_mental_model_ids || undefined, + }; - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error reflecting:", error); - return NextResponse.json({ error: "Failed to reflect" }, { status: 500 }); + // Add include options if specified + const includeOptions: any = {}; + if (include_facts) { + includeOptions.facts = {}; } + if (include_tool_calls) { + includeOptions.tool_calls = {}; + } + if (Object.keys(includeOptions).length > 0) { + requestBody.include = includeOptions; + } + + const response = await sdk.reflect({ + client: lowLevelClient, + path: { bank_id: bankId }, + body: requestBody, + }); + return respondWithSdk(response, "Failed to reflect"); } diff --git a/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts b/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts index 113405123..98968ee8a 100644 --- a/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts @@ -1,19 +1,15 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( request: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { - try { - const { agentId } = await params; - const response = await sdk.getAgentStats({ - client: lowLevelClient, - path: { bank_id: agentId }, - }); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching stats:", error); - return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 }); - } + const { agentId } = await params; + const response = await sdk.getAgentStats({ + client: lowLevelClient, + path: { bank_id: agentId }, + }); + return respondWithSdk(response, "Failed to fetch stats"); } diff --git a/hindsight-control-plane/src/lib/sdk-response.test.ts b/hindsight-control-plane/src/lib/sdk-response.test.ts new file mode 100644 index 000000000..2c0b1e0e3 --- /dev/null +++ b/hindsight-control-plane/src/lib/sdk-response.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { respondWithSdk, type SdkResult } from "./sdk-response"; + +const HTTP_OK = 200; +const HTTP_CREATED = 201; +const HTTP_UPSTREAM_503 = 503; +const HTTP_UPSTREAM_429 = 429; +const HTTP_UPSTREAM_500 = 500; +const HTTP_DEFAULT_FAILURE = 502; + +function makeResponse(status: number): Response { + return new Response(null, { status }); +} + +function ok(data: T, status = HTTP_OK): SdkResult { + return { data, error: undefined, response: makeResponse(status) }; +} + +function fail(error: unknown, status: number): SdkResult { + return { data: undefined, error, response: makeResponse(status) }; +} + +describe("respondWithSdk", () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe("success path", () => { + it("returns 200 with the data when result is successful", async () => { + const data = { foo: "bar", count: 42 }; + const response = respondWithSdk(ok(data), "Failed to fetch foo"); + expect(response.status).toBe(HTTP_OK); + await expect(response.json()).resolves.toEqual(data); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("honors custom success status (e.g. 201 for creates)", async () => { + const data = { id: "new-resource" }; + const response = respondWithSdk(ok(data), "Failed to create", HTTP_CREATED); + expect(response.status).toBe(HTTP_CREATED); + await expect(response.json()).resolves.toEqual(data); + }); + + it("returns 200 with array data", async () => { + const data = [{ id: 1 }, { id: 2 }]; + const response = respondWithSdk(ok(data), "Failed to list"); + expect(response.status).toBe(HTTP_OK); + await expect(response.json()).resolves.toEqual(data); + }); + + it("returns 200 with empty-object data (not treated as failure)", async () => { + const data = {}; + const response = respondWithSdk(ok(data), "Failed to fetch"); + expect(response.status).toBe(HTTP_OK); + await expect(response.json()).resolves.toEqual(data); + }); + }); + + describe("failure path", () => { + it("does NOT throw TypeError when SDK returns undefined data (regression for upstream bug)", () => { + // The original bug: NextResponse.json(undefined, ...) throws + // `TypeError: Value is not JSON serializable`. This helper must short- + // circuit before reaching that call site, so no throw should escape. + expect(() => + respondWithSdk(fail({ detail: "boom" }, HTTP_UPSTREAM_500), "Failed to fetch stats") + ).not.toThrow(); + }); + + it("passes the upstream HTTP status through (5xx)", async () => { + const response = respondWithSdk( + fail({ detail: "internal server error" }, HTTP_UPSTREAM_500), + "Failed to fetch stats" + ); + expect(response.status).toBe(HTTP_UPSTREAM_500); + }); + + it("passes the upstream HTTP status through (503)", async () => { + const response = respondWithSdk( + fail({ detail: "unavailable" }, HTTP_UPSTREAM_503), + "Failed to fetch banks" + ); + expect(response.status).toBe(HTTP_UPSTREAM_503); + }); + + it("passes the upstream HTTP status through (429)", async () => { + const response = respondWithSdk( + fail({ detail: "rate limited" }, HTTP_UPSTREAM_429), + "Failed to reflect" + ); + expect(response.status).toBe(HTTP_UPSTREAM_429); + }); + + it("includes the upstream error detail in the response body", async () => { + const upstreamError = { detail: "DiskFullError on shared memory" }; + const response = respondWithSdk( + fail(upstreamError, HTTP_UPSTREAM_500), + "Failed to fetch stats" + ); + const body = await response.json(); + expect(body).toEqual({ + error: "Failed to fetch stats", + upstream: { + status: HTTP_UPSTREAM_500, + detail: upstreamError, + }, + }); + }); + + it("logs the upstream status and error to console.error", () => { + const upstreamError = { detail: "boom" }; + respondWithSdk(fail(upstreamError, HTTP_UPSTREAM_500), "Failed to fetch stats"); + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to fetch stats:", { + upstreamStatus: HTTP_UPSTREAM_500, + upstreamError, + }); + }); + + it("falls back to 502 when result has no Response (network-level failure)", async () => { + // SDK call resolved but neither data nor a usable Response — treat as + // "couldn't reach upstream" rather than masking as 500 (which implies + // upstream answered). + const result: SdkResult = { + data: undefined, + error: new Error("ECONNREFUSED"), + }; + const response = respondWithSdk(result, "Failed to reach API"); + expect(response.status).toBe(HTTP_DEFAULT_FAILURE); + const body = await response.json(); + expect(body.upstream.status).toBe(HTTP_DEFAULT_FAILURE); + }); + + it("treats undefined data + undefined error as a failure (defensive)", async () => { + // Should not happen with a well-behaved SDK, but if both are missing we + // can't return undefined as JSON — treat as failure so the route doesn't + // silently 200 with `null`. + const result: SdkResult = { + data: undefined, + error: undefined, + response: makeResponse(HTTP_UPSTREAM_503), + }; + const response = respondWithSdk(result, "Failed to fetch"); + expect(response.status).toBe(HTTP_UPSTREAM_503); + const body = await response.json(); + expect(body.error).toBe("Failed to fetch"); + expect(body.upstream.detail).toBeNull(); + }); + }); +}); diff --git a/hindsight-control-plane/src/lib/sdk-response.ts b/hindsight-control-plane/src/lib/sdk-response.ts new file mode 100644 index 000000000..9fed79699 --- /dev/null +++ b/hindsight-control-plane/src/lib/sdk-response.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; + +const DEFAULT_UPSTREAM_STATUS = 502 as const; +const SUCCESS_STATUS = 200 as const; + +/** + * Minimal structural type for the @hey-api/client-fetch RequestResult shape + * (success: `{data, error: undefined}`, failure: `{data: undefined, error}`, + * both with `request`/`response`). Kept local so the helper has no compile-time + * dependency on the generated SDK package and can be unit-tested without it. + */ +export type SdkResult = { + data?: T; + error?: unknown; + request?: Request; + response?: Response; +}; + +/** + * Serialize the result of an SDK call into a NextResponse. + * + * Why this exists: `NextResponse.json(result.data, {status: 200})` throws + * `TypeError: Value is not JSON serializable` when `result.data` is `undefined` + * — which is exactly what the @hey-api/client-fetch SDK returns on non-2xx + * upstream responses (since it doesn't throw). The resulting TypeError gets + * caught and logged as the failure, hiding the real upstream error and forcing + * the response status to a hard-coded 500. + * + * This helper checks `result.error` / `result.data` first, surfaces the upstream + * status and error detail in the response body, and only serializes `data` on + * the success path. + * + * @param result The SDK call return value (`await sdk.someMethod(...)`). + * @param failureLabel Short human-readable label for the operation, used in + * both the log line and the response body's `error` field + * (e.g. `"Failed to fetch stats"`). + * @param successStatus HTTP status to use on the success path. Defaults to 200. + * Pass `201` for create endpoints. + */ +export function respondWithSdk( + result: SdkResult, + failureLabel: string, + successStatus: number = SUCCESS_STATUS +): NextResponse { + if (result.error !== undefined || result.data === undefined) { + const upstreamStatus = result.response?.status ?? DEFAULT_UPSTREAM_STATUS; + console.error(`${failureLabel}:`, { + upstreamStatus, + upstreamError: result.error, + }); + return NextResponse.json( + { + error: failureLabel, + upstream: { + status: upstreamStatus, + detail: result.error ?? null, + }, + }, + { status: upstreamStatus } + ); + } + + return NextResponse.json(result.data, { status: successStatus }); +} diff --git a/hindsight-control-plane/vitest.config.ts b/hindsight-control-plane/vitest.config.ts new file mode 100644 index 000000000..37495adf8 --- /dev/null +++ b/hindsight-control-plane/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/package-lock.json b/package-lock.json index 8077aef4a..2fdc69c85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "hindsight-all-npm": { "name": "@vectorize-io/hindsight-all", - "version": "0.6.1", + "version": "0.6.2", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", @@ -139,7 +139,7 @@ }, "hindsight-clients/typescript": { "name": "@vectorize-io/hindsight-client", - "version": "0.6.1", + "version": "0.6.2", "license": "MIT", "devDependencies": { "@hey-api/openapi-ts": "0.88.0", @@ -358,7 +358,7 @@ }, "hindsight-control-plane": { "name": "@vectorize-io/hindsight-control-plane", - "version": "0.6.1", + "version": "0.6.2", "license": "ISC", "dependencies": { "@chenglou/pretext": "^0.0.7", @@ -418,7 +418,8 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "prettier": "^3.7.4", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.50.0", + "vitest": "^4.1.7" } }, "hindsight-control-plane/node_modules/@chenglou/pretext": { @@ -434,6 +435,92 @@ "undici-types": "~7.16.0" } }, + "hindsight-control-plane/node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "hindsight-control-plane/node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "hindsight-control-plane/node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "hindsight-control-plane/node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "hindsight-control-plane/node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "hindsight-control-plane/node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "hindsight-control-plane/node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -463,12 +550,136 @@ "node": "^10 || ^12 || >=14" } }, + "hindsight-control-plane/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "hindsight-control-plane/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "hindsight-control-plane/node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "hindsight-control-plane/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "hindsight-docs": { "version": "0.0.0", "dependencies": {