import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { beforeEach, describe, expect, mock, test } from "bun:test"; // Capture enqueued PKB re-index jobs. const enqueuedJobs: Array<{ pkbRoot: string; absPath: string; }> = []; mock.module("../jobs/embed-pkb-file.js", () => ({ enqueuePkbIndexJob: (input: { pkbRoot: string; absPath: string }) => { enqueuedJobs.push(input); return "job-id"; }, })); // Capture calls into the fake Qdrant client. let scrollPoints: Array<{ id: string; payload: Record }> = []; const deleteCalls: Array<{ path: string }> = []; mock.module("../../../../../persistence/embeddings/qdrant-client.js", () => ({ getQdrantClient: () => ({ scrollByTargetType: async (_targetType: string) => scrollPoints, deleteByTargetTypeAndPath: async (_targetType: string, path: string) => { deleteCalls.push({ path }); }, }), resolveQdrantUrl: () => "http://127.0.0.1:6333", })); // Circuit breaker — pass-through. mock.module( "../../../../../persistence/embeddings/qdrant-circuit-breaker.js", () => ({ withQdrantBreaker: async (fn: () => Promise) => fn(), }), ); // indexPkbFile is not invoked from the reconcile path (we enqueue jobs // instead), but the pkb-index module imports the embedding pipeline which // pulls in a config. Stub it so module import doesn't explode. mock.module("../../../../../persistence/job-utils.js", () => ({ embedAndUpsert: async () => {}, })); import { reconcilePkbIndex } from "./pkb-reconcile.js"; function pkbPoint( path: string, contentHash: string, chunkIndex = 0, ): { id: string; payload: Record } { return { id: `${path}#${chunkIndex}`, payload: { target_type: "pkb_file", target_id: `${path}#${chunkIndex}`, path, chunk_index: chunkIndex, content_hash: contentHash, // Pre-existing points may carry a legacy memory_scope_id key; // reconciliation must tolerate it. memory_scope_id: "default", }, }; } // sha256(content).slice(0, 16) — precomputing isn't necessary; reconcile only // compares hashes for equality, so we read the real hash after scanning. import { scanPkbFiles } from "./pkb-index.js"; async function seedPkbAndHash( root: string, files: Array<{ path: string; content: string }>, ): Promise> { for (const f of files) { await writeFile(join(root, f.path), f.content); } const entries = await scanPkbFiles(root); const byPath = new Map(); for (const e of entries ?? []) { byPath.set(e.path, e.contentHash); } return byPath; } describe("reconcilePkbIndex", () => { beforeEach(() => { enqueuedJobs.length = 0; deleteCalls.length = 0; scrollPoints = []; }); test("fresh install: enqueues every on-disk file, deletes nothing", async () => { const root = await mkdtemp(join(tmpdir(), "pkb-reconcile-fresh-")); await seedPkbAndHash(root, [ { path: "a.md", content: "# A" }, { path: "b.md", content: "# B" }, ]); // Qdrant empty. scrollPoints = []; const result = await reconcilePkbIndex(root); expect(result.enqueued).toBe(2); expect(result.deleted).toBe(0); expect(enqueuedJobs).toHaveLength(2); const paths = new Set(enqueuedJobs.map((j) => j.absPath)); expect(paths.has(join(root, "a.md"))).toBe(true); expect(paths.has(join(root, "b.md"))).toBe(true); for (const job of enqueuedJobs) { expect(job.pkbRoot).toBe(root); } expect(deleteCalls).toHaveLength(0); }); test("steady state: matching hashes → no work enqueued or deleted", async () => { const root = await mkdtemp(join(tmpdir(), "pkb-reconcile-steady-")); const hashes = await seedPkbAndHash(root, [ { path: "a.md", content: "# A" }, { path: "b.md", content: "# B" }, ]); scrollPoints = [ pkbPoint("a.md", hashes.get("a.md")!), pkbPoint("b.md", hashes.get("b.md")!), ]; const result = await reconcilePkbIndex(root); expect(result.enqueued).toBe(0); expect(result.deleted).toBe(0); expect(enqueuedJobs).toHaveLength(0); expect(deleteCalls).toHaveLength(0); }); test("legacy-format points with scope-prefixed target ids do not trigger re-indexing", async () => { const root = await mkdtemp(join(tmpdir(), "pkb-reconcile-legacy-")); const hashes = await seedPkbAndHash(root, [ { path: "notes.md", content: "# Notes" }, ]); // Point indexed before scope removal: `_pkb_workspace:`-prefixed // target_id plus a legacy memory_scope_id payload key. Reconciliation // diffs on payload path + content_hash only, so a legacy point whose // hash matches disk must read as up to date. scrollPoints = [ { id: "_pkb_workspace:notes.md#0", payload: { target_type: "pkb_file", target_id: "_pkb_workspace:notes.md#0", path: "notes.md", chunk_index: 0, content_hash: hashes.get("notes.md")!, memory_scope_id: "_pkb_workspace", }, }, ]; const result = await reconcilePkbIndex(root); expect(result).toEqual({ enqueued: 0, deleted: 0 }); expect(enqueuedJobs).toHaveLength(0); expect(deleteCalls).toHaveLength(0); }); test("modified file: hash drift enqueues exactly one re-index", async () => { const root = await mkdtemp(join(tmpdir(), "pkb-reconcile-modified-")); const hashes = await seedPkbAndHash(root, [ { path: "a.md", content: "# A updated" }, { path: "b.md", content: "# B" }, ]); // Qdrant has a stale hash for a.md but matches for b.md. scrollPoints = [ pkbPoint("a.md", "0000000000000000"), pkbPoint("b.md", hashes.get("b.md")!), ]; const result = await reconcilePkbIndex(root); expect(result.enqueued).toBe(1); expect(result.deleted).toBe(0); expect(enqueuedJobs).toHaveLength(1); expect(enqueuedJobs[0].absPath).toBe(join(root, "a.md")); expect(deleteCalls).toHaveLength(0); }); test("stale indexed path not on disk: deletes that path, enqueues nothing", async () => { const root = await mkdtemp(join(tmpdir(), "pkb-reconcile-stale-")); const hashes = await seedPkbAndHash(root, [ { path: "a.md", content: "# A" }, ]); // Qdrant has a.md (matching) plus a phantom gone.md. scrollPoints = [ pkbPoint("a.md", hashes.get("a.md")!), pkbPoint("gone.md", "dead00dead00dead"), ]; const result = await reconcilePkbIndex(root); expect(result.enqueued).toBe(0); expect(result.deleted).toBe(1); expect(deleteCalls).toEqual([{ path: "gone.md" }]); expect(enqueuedJobs).toHaveLength(0); }); test("missing pkbRoot: indexed points are preserved (no wholesale deletion)", async () => { const parent = await mkdtemp(join(tmpdir(), "pkb-reconcile-missing-")); const missing = join(parent, "does-not-exist"); // Qdrant has points for paths that would look "stale" if we treated a // missing root as an empty disk view — the regression this test guards // against is those being wholesale-deleted. scrollPoints = [ pkbPoint("a.md", "aaaaaaaaaaaaaaaa"), pkbPoint("b.md", "bbbbbbbbbbbbbbbb"), pkbPoint("notes/c.md", "cccccccccccccccc"), ]; const result = await reconcilePkbIndex(missing); expect(result.enqueued).toBe(0); expect(result.deleted).toBe(0); expect(enqueuedJobs).toHaveLength(0); expect(deleteCalls).toHaveLength(0); }); test("pkbRoot existed then was removed: indexed points are preserved", async () => { const root = await mkdtemp(join(tmpdir(), "pkb-reconcile-vanished-")); await writeFile(join(root, "a.md"), "# A"); await rm(root, { recursive: true, force: true }); scrollPoints = [pkbPoint("a.md", "aaaaaaaaaaaaaaaa")]; const result = await reconcilePkbIndex(root); expect(result.enqueued).toBe(0); expect(result.deleted).toBe(0); expect(deleteCalls).toHaveLength(0); }); test("collapses multiple chunk points for the same path", async () => { const root = await mkdtemp(join(tmpdir(), "pkb-reconcile-chunks-")); const hashes = await seedPkbAndHash(root, [ { path: "a.md", content: "# A" }, ]); // Two chunk points for a.md with identical hashes. scrollPoints = [ pkbPoint("a.md", hashes.get("a.md")!, 0), pkbPoint("a.md", hashes.get("a.md")!, 1), ]; const result = await reconcilePkbIndex(root); expect(result.enqueued).toBe(0); expect(result.deleted).toBe(0); }); });