import { afterEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, symlinkSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { pathToFileURL } from 'url'; import type { BankStats, PluginConfig } from './types.js'; import type { BackfillCheckpoint, BackfillPlanEntry } from './backfill-lib.js'; const managerStart = vi.fn(); const managerStop = vi.fn(); const managerGetBaseUrl = vi.fn(() => 'http://127.0.0.1:9077'); vi.mock('@vectorize-io/hindsight-all', async () => { const actual = await vi.importActual('@vectorize-io/hindsight-all'); return { ...actual, HindsightServer: vi.fn(class { start = managerStart; stop = managerStop; getBaseUrl = managerGetBaseUrl; }), }; }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); managerStart.mockReset(); managerStop.mockReset(); managerGetBaseUrl.mockClear(); }); function makeEntry(bankId: string, sessionId: string): BackfillPlanEntry { return { filePath: `/tmp/${sessionId}.jsonl`, agentId: bankId, sessionId, bankId, documentId: `backfill::${bankId}::${sessionId}`, transcript: '[role: user]\nhello\n[user:end]', messageCount: 1, }; } function makeStats(overrides: Partial = {}): BankStats { return { bank_id: 'bank', total_nodes: 0, total_links: 0, total_documents: 0, pending_operations: 0, failed_operations: 0, pending_consolidation: 0, last_consolidated_at: null, total_observations: 0, ...overrides, }; } describe('backfill helpers', () => { it('resume skips only completed entries', async () => { const { filterEntriesForResume, splitResumeEntries } = await import('./backfill.js'); const entries = [makeEntry('bank-a', '1'), makeEntry('bank-a', '2'), makeEntry('bank-a', '3')]; const checkpoint: BackfillCheckpoint = { version: 1, entries: { 'bank-a::backfill::bank-a::1': { status: 'completed', bankId: 'bank-a', filePath: '/tmp/1', sessionId: '1', updatedAt: 'now' }, 'bank-a::backfill::bank-a::2': { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/2', sessionId: '2', updatedAt: 'now' }, 'bank-a::backfill::bank-a::3': { status: 'failed', bankId: 'bank-a', filePath: '/tmp/3', sessionId: '3', updatedAt: 'now' }, }, }; const resumable = filterEntriesForResume(entries, checkpoint, true); expect(resumable.map((entry) => entry.sessionId)).toEqual(['2', '3']); expect(splitResumeEntries(resumable, checkpoint, false).entriesToEnqueue.map((entry) => entry.sessionId)).toEqual(['2', '3']); expect(splitResumeEntries(resumable, checkpoint, true)).toEqual({ entriesToEnqueue: [entries[2]], alreadyEnqueuedKeys: ['bank-a::backfill::bank-a::2'], }); }); it('normalizes legacy queued checkpoint entries', async () => { const { loadCheckpoint } = await import('./backfill-lib.js'); const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-')); const checkpointPath = join(dir, 'checkpoint.json'); writeFileSync(checkpointPath, JSON.stringify({ version: 1, entries: { legacy: { status: 'queued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' }, }, }), 'utf8'); const checkpoint = loadCheckpoint(checkpointPath); expect(checkpoint.entries.legacy.status).toBe('enqueued'); }); it('marks drained entries completed and leaves aggregate-failure banks enqueued', async () => { const { applyDrainResults } = await import('./backfill.js'); const checkpoint: BackfillCheckpoint = { version: 1, entries: { a: { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' }, b: { status: 'enqueued', bankId: 'bank-b', filePath: '/tmp/b', sessionId: 'b', updatedAt: 'now' }, }, }; const touchedEntriesByBank = new Map([ ['bank-a', ['a']], ['bank-b', ['b']], ]); const finalStatsByBank = new Map([ ['bank-a', makeStats({ bank_id: 'bank-a', pending_operations: 0, failed_operations: 0 })], ['bank-b', makeStats({ bank_id: 'bank-b', pending_operations: 0, failed_operations: 2 })], ]); const initialFailedByBank = new Map([ ['bank-a', 0], ['bank-b', 0], ]); const result = applyDrainResults(checkpoint, touchedEntriesByBank, finalStatsByBank, initialFailedByBank); expect(result.completed).toBe(1); expect(result.unresolved).toBe(1); expect(result.warnings).toEqual([ 'bank bank-b reported 2 new failed operations during drain; leaving 1 checkpoint entries enqueued', ]); expect(checkpoint.entries.a.status).toBe('completed'); expect(checkpoint.entries.b.status).toBe('enqueued'); }); it('starts local daemon when no external API is configured and health check fails', async () => { const fetchMock = vi.fn().mockRejectedValue(new Error('offline')); vi.stubGlobal('fetch', fetchMock); const { createBackfillRuntime } = await import('./backfill.js'); const pluginConfig: PluginConfig = { apiPort: 9077, llmProvider: 'openai-codex', llmModel: 'gpt-5.4', }; const runtime = await createBackfillRuntime(pluginConfig); expect(managerStart).toHaveBeenCalledTimes(1); expect(runtime.apiUrl).toBe('http://127.0.0.1:9077'); await runtime.stop(); expect(managerStop).toHaveBeenCalledTimes(1); }); it('treats a symlinked bin path as direct execution', async () => { const { isDirectExecution } = await import('./backfill.js'); const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-bin-')); const modulePath = join(process.cwd(), 'dist', 'backfill.js'); const symlinkPath = join(dir, 'hindsight-openclaw-backfill'); symlinkSync(modulePath, symlinkPath); const moduleUrl = pathToFileURL(modulePath).href; expect(isDirectExecution(symlinkPath, moduleUrl)).toBe(true); expect(isDirectExecution(join(dir, 'other-entrypoint'), moduleUrl)).toBe(false); }); });