Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/api/migrations/004_doctor_cache.sql
Original file line number Diff line number Diff line change
@@ -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);
90 changes: 88 additions & 2 deletions apps/api/src/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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<string, unknown>();
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;
}
});
});
71 changes: 67 additions & 4 deletions apps/api/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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
}),
Expand All @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -530,3 +557,39 @@ function parseJsonObject(value: string): Record<string, unknown> {
return {};
}
}

async function cachedDoctorPacket(db: Db, packet: DoctorPacket, model: string): Promise<DoctorPacket | null> {
if (!packet.relatedDeploymentId) return null;
const cached = await db.maybeOne<CachedDoctorPacketRow>(
`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<void> {
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;
}
2 changes: 1 addition & 1 deletion apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,7 @@ function DoctorPanel({ doctor, loading }: { doctor?: DoctorPacket | null; loadin
</div>
<div>
<strong>Next action</strong>
<span>{doctor.safeToRetry ? 'Review and retry if nothing changed externally.' : 'Fix the app before retrying.'}</span>
<span>{doctor.rootCauseCategory === 'healthy' ? 'No repair needed.' : doctor.safeToRetry ? 'Review and retry if nothing changed externally.' : 'Fix the app before retrying.'}</span>
</div>
</div>
<div className="doctor-evidence">
Expand Down
Loading