From 7ede24cde98e2cadd911de82ea31df24b43637a3 Mon Sep 17 00:00:00 2001 From: Dan Kritzinger Date: Mon, 11 May 2026 15:24:53 +0200 Subject: [PATCH] Cache Doctor OpenRouter results --- apps/api/migrations/004_doctor_cache.sql | 10 +++ apps/api/src/doctor.test.ts | 90 +++++++++++++++++++++++- apps/api/src/doctor.ts | 71 +++++++++++++++++-- apps/web/src/main.tsx | 2 +- 4 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 apps/api/migrations/004_doctor_cache.sql diff --git a/apps/api/migrations/004_doctor_cache.sql b/apps/api/migrations/004_doctor_cache.sql new file mode 100644 index 0000000..49c2119 --- /dev/null +++ b/apps/api/migrations/004_doctor_cache.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS doctor_cache ( + deployment_id uuid PRIMARY KEY REFERENCES deployments(id) ON DELETE CASCADE, + root_cause_category text NOT NULL, + openrouter_model text, + packet_json jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_doctor_cache_updated_at ON doctor_cache(updated_at DESC); diff --git a/apps/api/src/doctor.test.ts b/apps/api/src/doctor.test.ts index d93cd08..d44b19c 100644 --- a/apps/api/src/doctor.test.ts +++ b/apps/api/src/doctor.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { buildDoctorPacket, normalizeOpenRouterSetting, publicOpenRouterSetting } from './doctor.js'; +import { + buildDoctorPacket, + enrichDoctorWithOpenRouter, + normalizeOpenRouterSetting, + publicOpenRouterSetting +} from './doctor.js'; import { loadConfig } from './config.js'; -import { decryptSecret } from './crypto.js'; +import { decryptSecret, encryptSecret } from './crypto.js'; import type { Db } from './db.js'; import type { AppRow, DeploymentRow } from './types.js'; @@ -84,6 +89,8 @@ describe('VibeStack Doctor', () => { expect(packet.healthCheckResult.status).toBe('passed'); expect(packet.summary).toContain('latest deployment succeeded'); expect(packet.suggestedFixPrompt).toContain('No repair is needed'); + expect(packet.suggestedFixPrompt).not.toContain('Fix the VibeStack deployment'); + expect(packet.safeToRetry).toBe(true); expect(packet.evidence.some((item) => item.label === 'Historical failure')).toBe(true); }); @@ -172,4 +179,83 @@ describe('VibeStack Doctor', () => { apiKeyConfigured: true }); }); + + it('caches OpenRouter enhancement per related deployment', async () => { + const config = loadConfig({ + DATABASE_URL: 'postgres://vibestack:vibestack@localhost:5432/vibestack', + VIBESTACK_SECRET_KEY: 'test-secret-key-for-doctor-cache', + VIBESTACK_PUBLIC_URL: 'https://vibestack.local.test' + }); + const openRouter = { + enabled: true, + model: 'openai/gpt-5.5', + encryptedApiKey: encryptSecret('sk-or-test', config.secretKey) + }; + const cacheRows = new Map(); + const db = { + maybeOne: async (sql: string, params: unknown[] = []) => { + if (sql.includes('platform_settings')) return { value_json: openRouter }; + if (sql.includes('doctor_cache')) return cacheRows.get(String(params[0])) ?? null; + return null; + }, + query: async (_sql: string, params: unknown[] = []) => { + cacheRows.set(String(params[0]), { + root_cause_category: params[1], + openrouter_model: params[2], + packet_json: JSON.parse(String(params[3])) + }); + return { rows: [], rowCount: 1 }; + } + } as unknown as Db; + const packet = await buildDoctorPacket({ + app, + deployments: [ + deployment({ + id: 'failed-deployment', + error_code: 'HEALTH_CHECK_FAILED', + error_details_json: { + checkedUrl: 'http://127.0.0.1:49152/health', + port: 3000, + healthCheckPath: '/health', + logExcerpt: 'Cannot GET /health' + } + }) + ], + secrets: [], + appLogs: [], + postgres: { enabled: false, logs: [] } + }); + let calls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + calls += 1; + return { + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: JSON.stringify({ + summary: 'Enhanced summary', + suggestedFixPrompt: 'Enhanced fix prompt' + }) + } + } + ] + }) + } as Response; + }) as typeof fetch; + + try { + const first = await enrichDoctorWithOpenRouter(db, config, packet); + const second = await enrichDoctorWithOpenRouter(db, config, packet); + + expect(calls).toBe(1); + expect(first.aiEnhancement?.summary).toBe('Enhanced summary'); + expect(second.aiEnhancement?.summary).toBe('Enhanced summary'); + expect(cacheRows.has('failed-deployment')).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/apps/api/src/doctor.ts b/apps/api/src/doctor.ts index 1d0dc81..d687953 100644 --- a/apps/api/src/doctor.ts +++ b/apps/api/src/doctor.ts @@ -49,6 +49,12 @@ export type DoctorPacket = { }; }; +type CachedDoctorPacketRow = { + root_cause_category: string; + openrouter_model: string | null; + packet_json: unknown; +}; + type OpenRouterSetting = { enabled?: boolean; model?: string; @@ -222,6 +228,9 @@ export async function enrichDoctorWithOpenRouter(db: Db, config: Config, packet: if (!packet.relatedDeploymentId) return packet; const setting = await getOpenRouterSetting(db, config); if (!setting.enabled || !setting.apiKey) return packet; + const model = setting.model ?? DEFAULT_OPENROUTER_MODEL; + const cached = await cachedDoctorPacket(db, packet, model); + if (cached) return cached; const prompt = [ 'You are VibeStack Doctor. Improve this deployment troubleshooting packet for a coding agent.', @@ -249,7 +258,7 @@ export async function enrichDoctorWithOpenRouter(db: Db, config: Config, packet: 'X-Title': 'VibeStack Doctor' }, body: JSON.stringify({ - model: setting.model ?? DEFAULT_OPENROUTER_MODEL, + model, messages: [{ role: 'user', content: prompt }], temperature: 0.2 }), @@ -268,14 +277,16 @@ export async function enrichDoctorWithOpenRouter(db: Db, config: Config, packet: ? parsed.suggestedFixPrompt.trim() : undefined; if (!summary && !suggestedFixPrompt) return packet; - return { + const enhanced = { ...packet, aiEnhancement: { - model: setting.model ?? DEFAULT_OPENROUTER_MODEL, + model, summary: summary ?? packet.summary, suggestedFixPrompt: suggestedFixPrompt ?? packet.suggestedFixPrompt } }; + await cacheDoctorPacket(db, enhanced, model); + return enhanced; } catch { return packet; } finally { @@ -329,7 +340,7 @@ function packetFor(input: { rootCauseCategory: input.category, evidence: input.evidence.slice(0, 18), suggestedFixPrompt: promptFor(input.app, input.category, input.evidence, input.healthCheckResult), - safeToRetry: input.category === 'unknown', + safeToRetry: input.category === 'unknown' || input.category === 'healthy', relatedDeploymentId: input.relatedDeploymentId, healthCheckResult: input.healthCheckResult, postgresHints: { @@ -374,6 +385,22 @@ function promptFor( evidence: DoctorEvidence[], health: DoctorPacket['healthCheckResult'] ): string { + if (category === 'healthy') { + const evidenceText = evidence + .slice(0, 8) + .map((item) => `- ${item.label}: ${item.value}`) + .join('\n'); + return [ + `Review the VibeStack Doctor result for app "${app.name}".`, + 'Root cause category: healthy.', + 'No repair is needed for the current deployment.', + 'Evidence:', + evidenceText || '- Current deployment is healthy.', + '', + instructionFor(category) + ].join('\n'); + } + const evidenceText = evidence .slice(0, 8) .map((item) => `- ${item.label}: ${item.value}`) @@ -530,3 +557,39 @@ function parseJsonObject(value: string): Record { return {}; } } + +async function cachedDoctorPacket(db: Db, packet: DoctorPacket, model: string): Promise { + if (!packet.relatedDeploymentId) return null; + const cached = await db.maybeOne( + `SELECT root_cause_category, openrouter_model, packet_json + FROM doctor_cache + WHERE deployment_id = $1`, + [packet.relatedDeploymentId] + ); + if (!cached) return null; + if (cached.root_cause_category !== packet.rootCauseCategory) return null; + if (cached.openrouter_model !== model) return null; + return normalizeCachedDoctorPacket(cached.packet_json, packet); +} + +async function cacheDoctorPacket(db: Db, packet: DoctorPacket, model: string): Promise { + if (!packet.relatedDeploymentId || !packet.aiEnhancement) return; + await db.query( + `INSERT INTO doctor_cache (deployment_id, root_cause_category, openrouter_model, packet_json, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (deployment_id) DO UPDATE + SET root_cause_category = EXCLUDED.root_cause_category, + openrouter_model = EXCLUDED.openrouter_model, + packet_json = EXCLUDED.packet_json, + updated_at = now()`, + [packet.relatedDeploymentId, packet.rootCauseCategory, model, JSON.stringify(packet)] + ); +} + +function normalizeCachedDoctorPacket(value: unknown, fallback: DoctorPacket): DoctorPacket | null { + const cached = asRecord(value); + if (typeof cached.summary !== 'string') return null; + if (cached.rootCauseCategory !== fallback.rootCauseCategory) return null; + if (cached.relatedDeploymentId !== fallback.relatedDeploymentId) return null; + return cached as DoctorPacket; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9775d6c..3825e76 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1000,7 +1000,7 @@ function DoctorPanel({ doctor, loading }: { doctor?: DoctorPacket | null; loadin
Next action - {doctor.safeToRetry ? 'Review and retry if nothing changed externally.' : 'Fix the app before retrying.'} + {doctor.rootCauseCategory === 'healthy' ? 'No repair needed.' : doctor.safeToRetry ? 'Review and retry if nothing changed externally.' : 'Fix the app before retrying.'}