From d7485aa6d0785987f494c2b341d7aea1bbece328 Mon Sep 17 00:00:00 2001 From: heybeaux Date: Tue, 13 Oct 2026 18:53:14 -0700 Subject: [PATCH 1/2] fix(dream-cycle,health): reap orphaned runs, harden identity parse, report build SHA ENG-8 (dream cycle): - DreamCycleRunTrackerService.failStaleReports(): RUNNING reports older than 6 h are marked FAILED before each scheduled cycle. - /v1/health dreamCycle.lastRun is now the newest COMPLETED run's completedAt (was: completedAt of the newest report of any status, so null whenever the newest report was RUNNING). Adds dreamCycle.runningSince and dreamCycle.stale. - Identity stage: DREAM_IDENTITY_MAX_TOKENS (default 4000, was 1500), output caps in the prompt, tolerant JSON extraction, shape normalisation. An unparseable or all-empty response no longer overwrites the last good snapshot; the warning logs completionTokens/maxTokens/likelyTruncated. ENG-16 item 3 (deployed-version visibility): - /v1/health gains top-level version, gitSha, builtAt. gitSha comes from ENGRAM_GIT_SHA / RAILWAY_GIT_COMMIT_SHA / SOURCE_COMMIT, else dist/build-info.json; only 7-40 hex chars are accepted. - `build` runs scripts/write-build-info.mjs (never fails the build); .dockerignore re-includes it (checked: docker build context contains it). Verified: tsc --noEmit exit 0; jest src/consolidation src/health 27 suites / 358 tests pass. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit fc0a43eb06ef947892480626d8d3cd88082f01d8) --- .dockerignore | 1 + package.json | 2 +- scripts/write-build-info.mjs | 34 +++++ .../dream-cycle-run-tracker.service.spec.ts | 37 ++++- .../dream-cycle-run-tracker.service.ts | 29 ++++ .../dream-cycle-scheduler.service.spec.ts | 34 +++++ .../dream-cycle-scheduler.service.ts | 8 + .../stages/dream-cycle-identity.stage.spec.ts | 141 +++++++++++++++++- .../stages/dream-cycle-identity.stage.ts | 70 +++++++-- src/health/build-info.spec.ts | 89 +++++++++++ src/health/build-info.ts | 70 +++++++++ src/health/health.controller.spec.ts | 66 +++++++- src/health/health.controller.ts | 51 +++++-- 13 files changed, 600 insertions(+), 32 deletions(-) create mode 100644 scripts/write-build-info.mjs create mode 100644 src/health/build-info.spec.ts create mode 100644 src/health/build-info.ts diff --git a/.dockerignore b/.dockerignore index a0470c09..fda92a93 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,6 +26,7 @@ jest.config.* .DS_Store worktrees/ scripts/ +!scripts/write-build-info.mjs docker-compose.override.yml* .eslintrc* .prettierrc* diff --git a/package.json b/package.json index d6e56fa9..375072ac 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "packageManager": "pnpm@9.15.4", "scripts": { - "build": "prisma generate && nest build", + "build": "prisma generate && nest build && node scripts/write-build-info.mjs", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", diff --git a/scripts/write-build-info.mjs b/scripts/write-build-info.mjs new file mode 100644 index 00000000..63075e6b --- /dev/null +++ b/scripts/write-build-info.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Writes dist/build-info.json so /v1/health can report which commit dist was +// built from (see src/health/build-info.ts). Never fails the build: missing git +// (e.g. Docker, where .git is ignored) just records gitSha: null. +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + +const SHA_RE = /^[0-9a-f]{7,40}$/i; + +function gitSha() { + for (const name of ['ENGRAM_GIT_SHA', 'RAILWAY_GIT_COMMIT_SHA', 'SOURCE_COMMIT']) { + const v = process.env[name]?.trim(); + if (v && SHA_RE.test(v)) return v.toLowerCase(); + } + try { + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return SHA_RE.test(sha) ? sha : null; + } catch { + return null; + } +} + +try { + const { version } = JSON.parse(readFileSync('package.json', 'utf8')); + const info = { version, gitSha: gitSha(), builtAt: new Date().toISOString() }; + mkdirSync('dist', { recursive: true }); + writeFileSync('dist/build-info.json', JSON.stringify(info, null, 2) + '\n'); + console.log(`build-info: ${info.version} ${info.gitSha ?? '(no sha)'}`); +} catch (err) { + console.warn(`build-info: skipped (${err instanceof Error ? err.message : err})`); +} diff --git a/src/consolidation/dream-cycle-run-tracker.service.spec.ts b/src/consolidation/dream-cycle-run-tracker.service.spec.ts index 771689b1..d995cd7e 100644 --- a/src/consolidation/dream-cycle-run-tracker.service.spec.ts +++ b/src/consolidation/dream-cycle-run-tracker.service.spec.ts @@ -1,5 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { DreamCycleRunTrackerService } from './dream-cycle-run-tracker.service'; +import { + DreamCycleRunTrackerService, + DREAM_CYCLE_STALE_MS, +} from './dream-cycle-run-tracker.service'; import { ServicePrismaService } from '../prisma/service-prisma.service'; const mockRecord = { @@ -15,6 +18,9 @@ const mockPrisma = { create: jest.fn().mockResolvedValue(mockRecord), update: jest.fn().mockResolvedValue({}), }, + dreamCycleReport: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, memory: { count: jest.fn().mockResolvedValue(42), }, @@ -179,4 +185,33 @@ describe('DreamCycleRunTrackerService', () => { expect(count).toBe(0); }); }); + + describe('failStaleReports', () => { + it('should mark RUNNING reports older than the threshold as FAILED', async () => { + mockPrisma.dreamCycleReport.updateMany.mockResolvedValueOnce({ + count: 3, + }); + const before = Date.now(); + + const count = await service.failStaleReports(); + + expect(count).toBe(3); + const call = mockPrisma.dreamCycleReport.updateMany.mock.calls[0][0]; + expect(call.where.status).toBe('RUNNING'); + const cutoff = call.where.startedAt.lt.getTime(); + expect(cutoff).toBeLessThanOrEqual(Date.now() - DREAM_CYCLE_STALE_MS); + expect(cutoff).toBeGreaterThanOrEqual(before - DREAM_CYCLE_STALE_MS); + expect(call.data.status).toBe('FAILED'); + expect(call.data.completedAt).toBeInstanceOf(Date); + expect(call.data.errors.push).toContain('orphaned'); + }); + + it('should honour a custom threshold', async () => { + await service.failStaleReports(60_000); + const call = mockPrisma.dreamCycleReport.updateMany.mock.calls[0][0]; + expect(Date.now() - call.where.startedAt.lt.getTime()).toBeLessThan( + 60_000 + 5_000, + ); + }); + }); }); diff --git a/src/consolidation/dream-cycle-run-tracker.service.ts b/src/consolidation/dream-cycle-run-tracker.service.ts index 93639324..0c089eaa 100644 --- a/src/consolidation/dream-cycle-run-tracker.service.ts +++ b/src/consolidation/dream-cycle-run-tracker.service.ts @@ -1,6 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { ServicePrismaService } from '../prisma/service-prisma.service'; +/** + * A per-user report still RUNNING after this long was orphaned by a crash or + * redeploy (local nightly runs finish in about an hour for 265 users). + */ +export const DREAM_CYCLE_STALE_MS = 6 * 60 * 60 * 1000; + export interface StageRunRecord { id: string; runId: string; @@ -81,6 +87,29 @@ export class DreamCycleRunTrackerService { }); } + /** + * Marks orphaned RUNNING reports as FAILED. Without this an orphan stays the + * newest report and /v1/health shows lastRun=null, status=RUNNING until the + * next cycle starts. + */ + async failStaleReports(staleMs = DREAM_CYCLE_STALE_MS): Promise { + const { count } = await this.prisma.dreamCycleReport.updateMany({ + where: { + status: 'RUNNING', + startedAt: { lt: new Date(Date.now() - staleMs) }, + }, + data: { + status: 'FAILED', + completedAt: new Date(), + errors: { push: 'orphaned: still RUNNING past stale threshold' }, + }, + }); + if (count > 0) { + this.logger.warn(`Marked ${count} orphaned Dream Cycle report(s) FAILED`); + } + return count; + } + async getTotalMemoryCount(userId?: string): Promise { return this.prisma.memory.count({ where: { deletedAt: null, ...(userId ? { userId } : {}) }, diff --git a/src/consolidation/dream-cycle-scheduler.service.spec.ts b/src/consolidation/dream-cycle-scheduler.service.spec.ts index ac58e376..6347722e 100644 --- a/src/consolidation/dream-cycle-scheduler.service.spec.ts +++ b/src/consolidation/dream-cycle-scheduler.service.spec.ts @@ -3,11 +3,13 @@ import { SchedulerRegistry } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; import { DreamCycleSchedulerService } from './dream-cycle-scheduler.service'; import { DreamCycleService } from './dream-cycle.service'; +import { DreamCycleRunTrackerService } from './dream-cycle-run-tracker.service'; import { Logger } from '@nestjs/common'; describe('DreamCycleSchedulerService', () => { let service: DreamCycleSchedulerService; let dreamCycle: { run: jest.Mock; runAsync: jest.Mock }; + let runTracker: { failStaleReports: jest.Mock }; let configValues: Record; let logSpy: jest.SpyInstance; let errorSpy: jest.SpyInstance; @@ -21,11 +23,13 @@ describe('DreamCycleSchedulerService', () => { }; dreamCycle = { run: jest.fn(), runAsync: jest.fn() }; + runTracker = { failStaleReports: jest.fn().mockResolvedValue(0) }; const module: TestingModule = await Test.createTestingModule({ providers: [ DreamCycleSchedulerService, { provide: DreamCycleService, useValue: dreamCycle }, + { provide: DreamCycleRunTrackerService, useValue: runTracker }, { provide: ConfigService, useValue: { @@ -63,6 +67,7 @@ describe('DreamCycleSchedulerService', () => { providers: [ DreamCycleSchedulerService, { provide: DreamCycleService, useValue: dreamCycle }, + { provide: DreamCycleRunTrackerService, useValue: runTracker }, { provide: ConfigService, useValue: { @@ -115,6 +120,34 @@ describe('DreamCycleSchedulerService', () => { ); }); + it('should fail orphaned reports before starting a run', async () => { + const order: string[] = []; + runTracker.failStaleReports.mockImplementation(async () => { + order.push('reap'); + return 2; + }); + dreamCycle.runAsync.mockImplementation(async () => { + order.push('run'); + return { runId: 'run-1', mode: 'queued' }; + }); + + await service.handleDreamCycleCron(); + + expect(order).toEqual(['reap', 'run']); + }); + + it('should still run when orphan cleanup throws', async () => { + runTracker.failStaleReports.mockRejectedValue(new Error('db down')); + dreamCycle.runAsync.mockResolvedValue({ runId: 'run-2', mode: 'queued' }); + + await service.handleDreamCycleCron(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Stale Dream Cycle cleanup failed'), + ); + expect(dreamCycle.runAsync).toHaveBeenCalledTimes(1); + }); + it('should skip when disabled', async () => { configValues.DREAM_CYCLE_ENABLED = 'false'; @@ -122,6 +155,7 @@ describe('DreamCycleSchedulerService', () => { providers: [ DreamCycleSchedulerService, { provide: DreamCycleService, useValue: dreamCycle }, + { provide: DreamCycleRunTrackerService, useValue: runTracker }, { provide: ConfigService, useValue: { diff --git a/src/consolidation/dream-cycle-scheduler.service.ts b/src/consolidation/dream-cycle-scheduler.service.ts index b5a2c060..695f097c 100644 --- a/src/consolidation/dream-cycle-scheduler.service.ts +++ b/src/consolidation/dream-cycle-scheduler.service.ts @@ -3,6 +3,7 @@ import { SchedulerRegistry } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; import { CronJob } from 'cron'; import { DreamCycleService } from './dream-cycle.service'; +import { DreamCycleRunTrackerService } from './dream-cycle-run-tracker.service'; @Injectable() export class DreamCycleSchedulerService implements OnModuleInit { @@ -12,6 +13,7 @@ export class DreamCycleSchedulerService implements OnModuleInit { constructor( private readonly dreamCycle: DreamCycleService, + private readonly runTracker: DreamCycleRunTrackerService, private readonly config: ConfigService, private readonly schedulerRegistry: SchedulerRegistry, ) { @@ -47,6 +49,12 @@ export class DreamCycleSchedulerService implements OnModuleInit { this.logger.log('Starting scheduled Dream Cycle run'); const start = Date.now(); + try { + await this.runTracker.failStaleReports(); + } catch (error) { + this.logger.error(`Stale Dream Cycle cleanup failed: ${error.message}`); + } + try { // ENG-97: Prefer BullMQ-based execution; falls back to sequential automatically const { runId, mode } = await this.dreamCycle.runAsync(); diff --git a/src/consolidation/stages/dream-cycle-identity.stage.spec.ts b/src/consolidation/stages/dream-cycle-identity.stage.spec.ts index 3b9ee0a3..074880fe 100644 --- a/src/consolidation/stages/dream-cycle-identity.stage.spec.ts +++ b/src/consolidation/stages/dream-cycle-identity.stage.spec.ts @@ -247,11 +247,150 @@ describe('HEY-176: Dream Cycle Identity Consolidation Stage', () => { mockLlm.chat.mockResolvedValue({ content: - '```json\n{"capabilities":[],"preferences":{},"trustScores":{},"behavioralTraits":[]}\n```', + '```json\n{"capabilities":[{"name":"Go","confidence":0.6,"lastSeen":"2026-01-01"}],"preferences":{},"trustScores":{},"behavioralTraits":[]}\n```', }); const result = await stage.run('user-1', false, 5); expect(result.snapshotId).toBe('snap-1'); }); + + describe('unparseable LLM output', () => { + const memories = Array.from({ length: 10 }, (_, i) => ({ + id: `mem-${i}`, + raw: `Memory ${i}`, + layer: 'IDENTITY', + memoryType: null, + subjectType: 'USER', + agentId: null, + source: 'EXPLICIT_STATEMENT', + effectiveScore: 0.8, + createdAt: new Date(), + metadata: null, + })); + + beforeEach(() => { + mockPrisma.memory.findMany.mockResolvedValue(memories); + mockPrisma.identitySnapshot.findFirst.mockResolvedValue(null); + mockPrisma.identitySnapshot.create.mockResolvedValue({ id: 'snap-1' }); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should not persist an empty snapshot when output is truncated', async () => { + // Real failure shape: max_tokens hit inside an unclosed ```json fence + mockLlm.chat.mockResolvedValue({ + content: '```json\n{\n "capabilities": [\n { "name": "Type', + usage: { + promptTokens: 1890, + completionTokens: 4000, + totalTokens: 5890, + }, + }); + + const result = await stage.run('user-1', false, 5); + + expect(result.snapshotId).toBeNull(); + expect(result.llmCalls).toBe(1); + expect(mockPrisma.identitySnapshot.create).not.toHaveBeenCalled(); + expect(mockPrisma.memory.updateMany).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('likelyTruncated=true'), + ); + }); + + it('should not persist a snapshot when every field is empty or invalid', async () => { + mockLlm.chat.mockResolvedValue({ + content: JSON.stringify({ + capabilities: 'none', + preferences: [], + trustScores: {}, + behavioralTraits: [], + }), + }); + + const result = await stage.run('user-1', false, 5); + + expect(result.snapshotId).toBeNull(); + expect(mockPrisma.identitySnapshot.create).not.toHaveBeenCalled(); + expect(mockPrisma.memory.updateMany).not.toHaveBeenCalled(); + }); + + it('should not persist a snapshot when output is a JSON array', async () => { + mockLlm.chat.mockResolvedValue({ content: '[]' }); + + const result = await stage.run('user-1', false, 5); + + expect(result.snapshotId).toBeNull(); + expect(mockPrisma.identitySnapshot.create).not.toHaveBeenCalled(); + }); + }); + + it('should request DREAM_IDENTITY_MAX_TOKENS (default 4000) from the LLM', async () => { + const memories = Array.from({ length: 5 }, (_, i) => ({ + id: `mem-${i}`, + raw: `Memory ${i}`, + layer: 'IDENTITY', + memoryType: null, + subjectType: 'USER', + agentId: null, + source: 'EXPLICIT_STATEMENT', + effectiveScore: 0.8, + createdAt: new Date(), + metadata: null, + })); + mockPrisma.memory.findMany.mockResolvedValue(memories); + mockPrisma.identitySnapshot.findFirst.mockResolvedValue(null); + mockPrisma.identitySnapshot.create.mockResolvedValue({ id: 'snap-1' }); + mockLlm.chat.mockResolvedValue({ content: '{}' }); + + await stage.run('user-1', false, 5); + expect(mockLlm.chat.mock.calls[0][1].maxTokens).toBe(4000); + + mockConfig.get.mockImplementation((key: string) => + key === 'DREAM_IDENTITY_MAX_TOKENS' ? '2500' : undefined, + ); + const configured = new DreamCycleIdentityStage( + mockPrisma, + mockLlm, + mockConfig, + ); + await configured.run('user-1', false, 5); + expect(mockLlm.chat.mock.calls[1][1].maxTokens).toBe(2500); + }); + + it('should parse JSON preceded by prose and fill missing fields', async () => { + const memories = Array.from({ length: 5 }, (_, i) => ({ + id: `mem-${i}`, + raw: `Memory ${i}`, + layer: 'IDENTITY', + memoryType: null, + subjectType: 'USER', + agentId: null, + source: 'EXPLICIT_STATEMENT', + effectiveScore: 0.8, + createdAt: new Date(), + metadata: null, + })); + mockPrisma.memory.findMany.mockResolvedValue(memories); + mockPrisma.identitySnapshot.findFirst.mockResolvedValue(null); + mockPrisma.identitySnapshot.create.mockResolvedValue({ id: 'snap-2' }); + mockLlm.chat.mockResolvedValue({ + content: + 'Here is the profile:\n{"capabilities":[{"name":"Rust","confidence":0.7,"lastSeen":"2026-10-01"}]}', + }); + + const result = await stage.run('user-1', false, 5); + + expect(result.snapshotId).toBe('snap-2'); + expect(result.capabilitiesExtracted).toBe(1); + expect(mockPrisma.identitySnapshot.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + preferences: {}, + trustScores: {}, + behavioralTraits: [], + }), + }); + }); }); }); diff --git a/src/consolidation/stages/dream-cycle-identity.stage.ts b/src/consolidation/stages/dream-cycle-identity.stage.ts index 5ac53b9a..0d51e567 100644 --- a/src/consolidation/stages/dream-cycle-identity.stage.ts +++ b/src/consolidation/stages/dream-cycle-identity.stage.ts @@ -43,6 +43,7 @@ export interface IdentitySnapshotData { @Injectable() export class DreamCycleIdentityStage { private readonly maxSourceMemories: number; + private readonly maxTokens: number; constructor( private readonly prisma: ServicePrismaService, @@ -53,6 +54,13 @@ export class DreamCycleIdentityStage { this.config.get('DREAM_IDENTITY_MAX_MEMORIES') ?? '100', 10, ); + // Was 1500. One local user's profile failed to parse every night for + // 102 nights; truncation is the suspected cause, and the parse-failure + // log below reports completionTokens vs maxTokens to confirm it. + this.maxTokens = parseInt( + this.config.get('DREAM_IDENTITY_MAX_TOKENS') ?? '4000', + 10, + ); } async run( @@ -84,6 +92,12 @@ export class DreamCycleIdentityStage { // 3. Extract identity data via LLM const identityData = await this.extractIdentity(memories, previousSnapshot); + // Unparseable output must not overwrite the previous snapshot with an + // empty one; keep the last good profile and retry next cycle. + if (!identityData) { + return { ...this.emptyResult(), llmCalls: 1 }; + } + if (dryRun) { console.log( `[DreamCycle:Identity] DRY RUN — would create snapshot with ` + @@ -191,7 +205,7 @@ export class DreamCycleIdentityStage { createdAt: Date; }>, previousSnapshot: any | null, - ): Promise { + ): Promise { const memorySummary = memories .slice(0, 60) .map( @@ -240,6 +254,8 @@ Respond in JSON: ] } +Limits: at most 15 capabilities, 10 preferences, 10 trust scores and 10 behavioral traits. Keep each evidence string under 25 words. Return only the JSON object. + Be specific. Use real data from the memories. Don't invent traits that aren't evidenced.`, }, { @@ -251,26 +267,24 @@ Be specific. Use real data from the memories. Don't invent traits that aren't ev provider: 'openai', model: 'gpt-4o-mini', temperature: 0.3, - maxTokens: 1500, + maxTokens: this.maxTokens, }, ); try { - return this.parseJsonResponse(response.content); + return this.normalize(this.parseJsonResponse(response.content)); } catch { + const completionTokens = response.usage?.completionTokens ?? 0; console.warn( - '[DreamCycle:Identity] Failed to parse LLM response, returning empty snapshot', + '[DreamCycle:Identity] Failed to parse LLM response, skipping snapshot ' + + `(chars=${response.content?.length ?? 0}, completionTokens=${completionTokens}, ` + + `maxTokens=${this.maxTokens}, likelyTruncated=${completionTokens >= this.maxTokens})`, ); - return { - capabilities: [], - preferences: {}, - trustScores: {}, - behavioralTraits: [], - }; + return null; } } - private parseJsonResponse(content: string): IdentitySnapshotData { + private parseJsonResponse(content: string): unknown { try { return JSON.parse(content); } catch { @@ -278,10 +292,44 @@ Be specific. Use real data from the memories. Don't invent traits that aren't ev if (jsonMatch) { return JSON.parse(jsonMatch[1]); } + const start = content.indexOf('{'); + const end = content.lastIndexOf('}'); + if (start !== -1 && end > start) { + return JSON.parse(content.slice(start, end + 1)); + } throw new Error('Could not parse JSON'); } } + private normalize(parsed: unknown): IdentitySnapshotData { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Identity response is not a JSON object'); + } + const p = parsed as Partial; + const isRecord = (v: unknown) => + !!v && typeof v === 'object' && !Array.isArray(v); + const data: IdentitySnapshotData = { + capabilities: Array.isArray(p.capabilities) ? p.capabilities : [], + preferences: isRecord(p.preferences) ? p.preferences! : {}, + trustScores: isRecord(p.trustScores) ? p.trustScores! : {}, + behavioralTraits: Array.isArray(p.behavioralTraits) + ? p.behavioralTraits + : [], + }; + // An all-empty profile from a user with identity memories is a failed + // extraction, not a real result; treat it like a parse failure so it + // cannot overwrite the last good snapshot. + if ( + data.capabilities.length === 0 && + data.behavioralTraits.length === 0 && + Object.keys(data.preferences).length === 0 && + Object.keys(data.trustScores).length === 0 + ) { + throw new Error('Identity response has no usable fields'); + } + return data; + } + private emptyResult(): IdentityStageResult { return { snapshotId: null, diff --git a/src/health/build-info.spec.ts b/src/health/build-info.spec.ts new file mode 100644 index 00000000..fbd5cc66 --- /dev/null +++ b/src/health/build-info.spec.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { resolveBuildInfo } from './build-info'; + +describe('resolveBuildInfo', () => { + let root: string; + let dist: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'build-info-')); + dist = join(root, 'dist'); + mkdirSync(dist); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ version: '1.5.0' }), + ); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const stamp = (info: Record) => + writeFileSync(join(dist, 'build-info.json'), JSON.stringify(info)); + + it('reads version from package.json and nulls when nothing else is known', () => { + expect(resolveBuildInfo({}, dist)).toEqual({ + version: '1.5.0', + gitSha: null, + builtAt: null, + }); + }); + + it('prefers an env SHA over the build stamp', () => { + stamp({ gitSha: 'a'.repeat(40), builtAt: '2026-10-13T00:00:00.000Z' }); + + const info = resolveBuildInfo( + { RAILWAY_GIT_COMMIT_SHA: 'B'.repeat(40) }, + dist, + ); + + expect(info.gitSha).toBe('b'.repeat(40)); + expect(info.builtAt).toBe('2026-10-13T00:00:00.000Z'); + }); + + it('checks ENGRAM_GIT_SHA before RAILWAY_GIT_COMMIT_SHA', () => { + const info = resolveBuildInfo( + { ENGRAM_GIT_SHA: 'ef4e30f', RAILWAY_GIT_COMMIT_SHA: 'c'.repeat(40) }, + dist, + ); + + expect(info.gitSha).toBe('ef4e30f'); + }); + + it('falls back to the build stamp SHA when no env var is set', () => { + stamp({ gitSha: '0bebe49aaaa', builtAt: '2026-07-04T12:00:00.000Z' }); + + expect(resolveBuildInfo({}, dist)).toEqual({ + version: '1.5.0', + gitSha: '0bebe49aaaa', + builtAt: '2026-07-04T12:00:00.000Z', + }); + }); + + it('rejects values that are not hex SHAs instead of echoing them', () => { + stamp({ gitSha: 'not-a-sha', builtAt: 'yesterday' }); + + const info = resolveBuildInfo( + { ENGRAM_GIT_SHA: '