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
39 changes: 39 additions & 0 deletions apps/api/src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,45 @@ function deployment(overrides: Partial<DeploymentRow>): DeploymentRow {
}

describe('VibeStack Doctor', () => {
it('reports a healthy current state when a newer successful deployment supersedes an older failure', async () => {
const packet = await buildDoctorPacket({
app: { ...app, status: 'running', current_deployment_id: 'succeeded-deployment' },
deployments: [
deployment({
id: 'succeeded-deployment',
version_number: 3,
status: 'succeeded',
error_code: null,
error_message: null,
error_details_json: null
}),
deployment({
id: 'failed-deployment',
version_number: 2,
status: 'failed',
error_code: 'HEALTH_CHECK_FAILED',
error_message: 'Old failure',
error_details_json: {
checkedUrl: 'http://127.0.0.1:49152/health',
port: 3000,
healthCheckPath: '/health',
logExcerpt: 'Cannot GET /health'
}
})
],
secrets: [],
appLogs: ['Listening on 3000', 'DB ready'],
postgres: { enabled: true, logs: [] }
});

expect(packet.rootCauseCategory).toBe('healthy');
expect(packet.relatedDeploymentId).toBe('succeeded-deployment');
expect(packet.healthCheckResult.status).toBe('passed');
expect(packet.summary).toContain('latest deployment succeeded');
expect(packet.suggestedFixPrompt).toContain('No repair is needed');
expect(packet.evidence.some((item) => item.label === 'Historical failure')).toBe(true);
});

it('classifies missing health routes from failed health checks', async () => {
const packet = await buildDoctorPacket({
app,
Expand Down
42 changes: 38 additions & 4 deletions apps/api/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type DoctorRootCause =
| 'build_failure'
| 'container_start_failure'
| 'health_check_failure'
| 'healthy'
| 'unknown';

export type DoctorEvidence = {
Expand Down Expand Up @@ -124,11 +125,15 @@ async function getStoredOpenRouterSetting(db: Db): Promise<OpenRouterSetting> {
}

export async function buildDoctorPacket(input: DoctorInput): Promise<DoctorPacket> {
const failedDeployment =
input.deployments.find((deployment) => deployment.status === 'failed') ??
(input.app.status === 'failed' ? input.deployments[0] : undefined);
const latestDeployment = input.deployments[0];
const latestFailed = latestDeployment?.status === 'failed' ? latestDeployment : undefined;
const historicalFailure =
latestDeployment?.status === 'succeeded'
? input.deployments.find((deployment) => deployment.status === 'failed')
: undefined;
const failedDeployment = latestFailed ?? (input.app.status === 'failed' ? latestDeployment : undefined);
const details = asRecord(failedDeployment?.error_details_json);
const manifest = asRecord(failedDeployment?.manifest ?? input.deployments[0]?.manifest);
const manifest = asRecord(failedDeployment?.manifest ?? latestDeployment?.manifest);
const healthPath = stringValue(details.healthCheckPath) ?? stringValue(manifest.healthCheckPath);
const checkedUrl = stringValue(details.checkedUrl);
const port = numberValue(details.port) ?? numberValue(manifest.port);
Expand All @@ -140,6 +145,31 @@ export async function buildDoctorPacket(input: DoctorInput): Promise<DoctorPacke
});
const evidence: DoctorEvidence[] = [];

if (latestDeployment?.status === 'succeeded' && input.app.status === 'running') {
evidence.push({
source: 'deployment',
label: 'Current deployment',
value: `Latest deployment v${latestDeployment.version_number} succeeded.`,
severity: 'info'
});
if (historicalFailure) {
evidence.push({
source: 'deployment',
label: 'Historical failure',
value: `Older deployment v${historicalFailure.version_number} failed with ${historicalFailure.error_code ?? 'an unknown error'}.`,
severity: 'info'
});
}
return packetFor({
app: input.app,
category: 'healthy',
evidence,
relatedDeploymentId: latestDeployment.id,
healthCheckResult: { status: 'passed', checkedUrl, port, path: healthPath },
postgresEnabled: input.postgres.enabled
});
}

if (!failedDeployment) {
evidence.push({
source: 'app',
Expand Down Expand Up @@ -313,6 +343,8 @@ function packetFor(input: {
function summaryFor(app: AppRow, category: DoctorRootCause): string {
const prefix = `${app.name} diagnosis`;
switch (category) {
case 'healthy':
return `${prefix}: current app state looks healthy and the latest deployment succeeded.`;
case 'missing_health_route':
return `${prefix}: the configured health check route appears to be missing or returning a non-2xx response.`;
case 'wrong_bind_host':
Expand Down Expand Up @@ -368,6 +400,8 @@ function instructionFor(category: DoctorRootCause): string {
switch (category) {
case 'missing_health_route':
return 'Add or repair a fast unauthenticated health route that returns HTTP 2xx, then set vibestack.json healthCheckPath to that route.';
case 'healthy':
return 'No repair is needed for the current deployment. Ignore older failed deployment attempts unless the current app starts failing again.';
case 'wrong_bind_host':
return 'Change the web server to listen on 0.0.0.0 inside the container, not localhost or 127.0.0.1.';
case 'wrong_port':
Expand Down
Loading