diff --git a/scripts/check-migrations.mjs b/scripts/check-migrations.mjs index 8d5ca2a30d..a05a50220c 100644 --- a/scripts/check-migrations.mjs +++ b/scripts/check-migrations.mjs @@ -17,6 +17,9 @@ // • 0074 — both 0074_ai_review_cache (#1462) and 0074_orb_self_enrollment_disabled (#1465, a bare ADD COLUMN) // merged + deployed before the collision surfaced; the column already exists in prod, so a rename would // re-run the ALTER and fail. Grandfathered for the same reason as 0015/0017. +// • 0090 — both 0090_contributor_cap_label (#2479) and 0090_pull_request_detail_sync_head_sha (#2527) +// merged with bare ADD COLUMN statements. Preserve both filenames so already-applied databases never +// replay either ALTER under a new migration name. import { readdirSync, readFileSync } from "node:fs"; const DIR = process.env.CHECK_MIGRATIONS_DIR || "migrations"; @@ -25,6 +28,7 @@ const KNOWN_DUPLICATES = new Map([ [15, new Set(["0015_github_agent_command_feedback.sql", "0015_product_usage_events.sql"])], [17, new Set(["0017_agent_recommendation_outcomes.sql", "0017_product_usage_role_retention_rollups.sql"])], [74, new Set(["0074_ai_review_cache.sql", "0074_orb_self_enrollment_disabled.sql"])], + [90, new Set(["0090_contributor_cap_label.sql", "0090_pull_request_detail_sync_head_sha.sql"])], ]); const fail = (message) => { diff --git a/src/selfhost/health.ts b/src/selfhost/health.ts index 5ce06868ee..79907ee0ff 100644 --- a/src/selfhost/health.ts +++ b/src/selfhost/health.ts @@ -7,6 +7,42 @@ export interface Readiness { checks: Record; } +export type HealthBackend = "sqlite" | "postgres"; + +export interface HealthBody { + status: "ok"; + version: string; + uptimeSeconds: number; + backend: HealthBackend; +} + +function nonBlank(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveHealthVersion( + env: { GITTENSORY_VERSION?: string | undefined }, + packageVersion?: string, +): string { + const envVersion = nonBlank(env.GITTENSORY_VERSION); + if (envVersion) return envVersion; + return nonBlank(packageVersion) ?? "unknown"; +} + +export function buildHealthBody(opts: { + version?: string; + startedAt: number; + dbBackend: HealthBackend; +}): HealthBody { + return { + status: "ok", + version: nonBlank(opts.version) ?? "unknown", + uptimeSeconds: Math.max(0, Math.floor((Date.now() - opts.startedAt) / 1000)), + backend: opts.dbBackend, + }; +} + /** An extra readiness check for a CONFIGURED optional backend (Redis, Qdrant …). `check` resolves true when the * backend is reachable; it OWNS its own timeout (the caller wires it that way) so a hung backend can't hang /ready. * A configured backend that fails to answer means the instance is degraded — a multi-instance load balancer should @@ -27,8 +63,8 @@ export async function readiness(db: D1Database, probes: ReadinessProbe[] = []): } try { const row = await db.prepare("SELECT COUNT(*) AS c FROM _selfhost_migrations").first<{ c: number }>(); - /* v8 ignore next */ // COUNT(*) always returns exactly one row, so the row?./?? 0 guards never fire - migrations = Number(row?.c ?? 0) > 0; + // COUNT(*) always returns one row on D1/SQLite; if an adapter violates that, this try/catch fails closed. + migrations = Number(row!.c) > 0; } catch { /* migrations table missing */ } diff --git a/src/server.ts b/src/server.ts index 8c78b4038c..7aa8422e90 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import { delimiter, join } from "node:path"; import { randomUUID } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; import { serve } from "@hono/node-server"; +import packageJson from "../package.json"; import worker from "./index"; import { processJob } from "./queue/processors"; import { @@ -36,7 +37,9 @@ import { isOrbBrokerMode, registerOrbRelayTarget } from "./orb/broker-client"; import { exportOrbBatch } from "./selfhost/orb-collector"; import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter"; import { + buildHealthBody, readiness, + resolveHealthVersion, sqliteBackupAdvisory, type ReadinessProbe, } from "./selfhost/health"; @@ -325,10 +328,15 @@ async function main(): Promise { const backend = usePostgres ? await buildPostgresBackend(databaseUrl as string, consume) : buildSqliteBackend(consume); + const dbBackend = usePostgres ? "postgres" : "sqlite"; + const healthVersion = resolveHealthVersion( + { GITTENSORY_VERSION: process.env.GITTENSORY_VERSION }, + packageJson.version, + ); console.log( JSON.stringify({ event: "selfhost_backend", - backend: usePostgres ? "postgres" : "sqlite", + backend: dbBackend, }), ); // Data-safety advisory (#8): warn LOUDLY at boot if running on a single SQLite file with no acknowledged backup, @@ -559,9 +567,10 @@ async function main(): Promise { fetch: async (request: Request) => { const path = new URL(request.url).pathname; if (path === "/health") - return new Response(JSON.stringify({ status: "ok" }), { - headers: { "content-type": "application/json" }, - }); + return new Response( + JSON.stringify(buildHealthBody({ version: healthVersion, startedAt, dbBackend })), + { headers: { "content-type": "application/json" } }, + ); if (path === "/ready") { const r = await readiness(backend.db, readinessProbes); return new Response(JSON.stringify(r), { diff --git a/test/unit/check-migrations-script.test.ts b/test/unit/check-migrations-script.test.ts index 1320c32f88..65bbb20066 100644 --- a/test/unit/check-migrations-script.test.ts +++ b/test/unit/check-migrations-script.test.ts @@ -31,7 +31,7 @@ describe("check-migrations script", () => { it("reports every grandfathered duplicate migration number in the success summary", () => { const output = execFileSync(process.execPath, ["scripts/check-migrations.mjs"], { encoding: "utf8" }); - expect(output).toContain("(3 grandfathered duplicates: 0015, 0017, 0074)"); + expect(output).toContain("(4 grandfathered duplicates: 0015, 0017, 0074, 0090)"); }); it("rejects a migration that creates a temporary object (the D1 remote authorizer blocks it)", () => { diff --git a/test/unit/selfhost-health.test.ts b/test/unit/selfhost-health.test.ts index 0c5c53132d..e07885b3ec 100644 --- a/test/unit/selfhost-health.test.ts +++ b/test/unit/selfhost-health.test.ts @@ -1,7 +1,72 @@ import { DatabaseSync } from "node:sqlite"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter"; -import { readiness, sqliteBackupAdvisory } from "../../src/selfhost/health"; +import { + buildHealthBody, + readiness, + resolveHealthVersion, + sqliteBackupAdvisory, +} from "../../src/selfhost/health"; + +describe("buildHealthBody (#2077)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("reports the configured version, rounded uptime, and Postgres backend", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-02T12:00:10.900Z")); + + expect( + buildHealthBody({ + version: "2026.7.2", + startedAt: Date.parse("2026-07-02T12:00:00.100Z"), + dbBackend: "postgres", + }), + ).toEqual({ + status: "ok", + version: "2026.7.2", + uptimeSeconds: 10, + backend: "postgres", + }); + }); + + it("falls back to unknown and never reports negative uptime", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-02T12:00:00.000Z")); + + expect( + buildHealthBody({ + version: " ", + startedAt: Date.parse("2026-07-02T12:00:02.000Z"), + dbBackend: "sqlite", + }), + ).toEqual({ + status: "ok", + version: "unknown", + uptimeSeconds: 0, + backend: "sqlite", + }); + }); +}); + +describe("resolveHealthVersion (#2077)", () => { + it("prefers the image version over the package fallback", () => { + expect(resolveHealthVersion({ GITTENSORY_VERSION: " image-2026.07.02 " }, "0.1.0")).toBe( + "image-2026.07.02", + ); + }); + + it("uses the package fallback when the image version is absent or blank", () => { + expect(resolveHealthVersion({}, "0.1.0")).toBe("0.1.0"); + expect(resolveHealthVersion({ GITTENSORY_VERSION: " " }, "0.1.0")).toBe("0.1.0"); + }); + + it("reports unknown when no nonblank version is available", () => { + expect(resolveHealthVersion({}, undefined)).toBe("unknown"); + expect(resolveHealthVersion({ GITTENSORY_VERSION: "" }, " ")).toBe("unknown"); + }); +}); describe("sqliteBackupAdvisory (#8 data-safety)", () => { it("warns on SQLite without an acknowledged backup, and is silent otherwise", () => { @@ -28,7 +93,9 @@ describe("readiness (#982)", () => { it("reports db=false and migrations=false when the SELECT 1 probe throws (db down)", async () => { const throwingDb = { prepare: () => ({ - bind: function() { return this; }, + bind: function() { + return this; + }, first: () => Promise.reject(new Error("sqlite_io_error")), all: () => Promise.reject(new Error("sqlite_io_error")), run: () => Promise.reject(new Error("sqlite_io_error")),