Files
socraticode/tests/unit/context-artifacts-checkpoint.test.ts
Aleksey Chugarev 2007a18865 fix(context): checkpoint artifact metadata after each successful index (#52)
indexAllArtifacts and ensureArtifactsIndexed previously called saveContextMetadata only once, after the entire indexing pass completed. When the underlying loop took longer than the MCP client's tool-call timeout, completed artifacts appeared unindexed because their state was never persisted, and partial progress was lost.

This patch saves the metadata snapshot after every successfully indexed artifact, so each artifact's success is durable as soon as the indexing for it returns. It also seeds the in-flight stateMap from the previously-loaded existingStates so that interrupted runs can preserve completed work for artifacts already finished, and uses that same original snapshot to identify orphan artifacts that need cleanup when the config has changed.

Backwards compatible: a successful full run produces exactly the same final on-disk state as before. The only behavioural difference is in the interrupted-mid-run case, where the new code retains more state instead of losing everything since the last full pass.

Tests: 3 new cases in tests/unit/context-artifacts-checkpoint.test.ts covering the checkpointing path during full indexing, preservation of earlier successes when a later artifact fails, and preservation of up-to-date states while re-indexing stale ones. Existing unit tests continue to pass unchanged.

Co-authored-by: jackblackjack chugarev@gmail.com
2026-05-06 10:27:00 +01:00

148 lines
5.1 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ArtifactIndexState } from "../../src/types.js";
let tempDir: string;
let existingMetadata: ArtifactIndexState[] | null = null;
const saveCalls: ArtifactIndexState[][] = [];
vi.mock("../../src/services/embeddings.js", () => ({
generateEmbeddings: vi.fn(async (texts: string[]) =>
texts.map(() => [0.1, 0.2, 0.3]),
),
prepareDocumentText: vi.fn((content: string, filePath: string) =>
`search_document: ${filePath}\n${content}`,
),
}));
vi.mock("../../src/services/qdrant.js", () => ({
deleteArtifactChunks: vi.fn(async () => undefined),
deleteCollection: vi.fn(async () => undefined),
deleteContextMetadata: vi.fn(async () => undefined),
ensureCollection: vi.fn(async () => undefined),
ensurePayloadIndex: vi.fn(async () => undefined),
getCollectionInfo: vi.fn(async () => ({ pointsCount: 1 })),
loadContextMetadata: vi.fn(async () => existingMetadata),
saveContextMetadata: vi.fn(async (
_collection: string,
_projectPath: string,
artifacts: ArtifactIndexState[],
) => {
saveCalls.push([...artifacts]);
existingMetadata = [...artifacts];
}),
searchChunks: vi.fn(async () => []),
searchChunksWithFilter: vi.fn(async () => []),
upsertPreEmbeddedChunks: vi.fn(async () => ({ pointsSkipped: 0 })),
}));
const { ensureArtifactsIndexed, indexAllArtifacts } = await import(
"../../src/services/context-artifacts.js"
);
beforeEach(async () => {
existingMetadata = null;
saveCalls.length = 0;
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "socraticode-checkpoint-test-"));
});
afterEach(async () => {
await fsp.rm(tempDir, { recursive: true, force: true });
});
async function createProject(files: Record<string, string>): Promise<string> {
const projectDir = path.join(tempDir, `proj-${Math.random().toString(36).slice(2)}`);
for (const [filePath, content] of Object.entries(files)) {
const fullPath = path.join(projectDir, filePath);
await fsp.mkdir(path.dirname(fullPath), { recursive: true });
await fsp.writeFile(fullPath, content);
}
return projectDir;
}
describe("context artifact metadata checkpoints", () => {
it("saves metadata after each artifact during full indexing", async () => {
const projectDir = await createProject({
".socraticodecontextartifacts.json": JSON.stringify({
artifacts: [
{ name: "a", path: "./a.md", description: "Artifact A" },
{ name: "b", path: "./b.md", description: "Artifact B" },
],
}),
"a.md": "# A",
"b.md": "# B",
});
const { indexed, errors } = await indexAllArtifacts(projectDir);
expect(errors).toHaveLength(0);
expect(indexed.map((a) => a.name)).toEqual(["a", "b"]);
expect(saveCalls.length).toBeGreaterThanOrEqual(2);
expect(saveCalls[0].map((a) => a.name)).toEqual(["a"]);
expect(saveCalls[1].map((a) => a.name)).toEqual(["a", "b"]);
});
it("keeps a successful artifact checkpoint when a later artifact fails", async () => {
const projectDir = await createProject({
".socraticodecontextartifacts.json": JSON.stringify({
artifacts: [
{ name: "ok", path: "./ok.md", description: "OK artifact" },
{ name: "missing", path: "./missing.md", description: "Missing artifact" },
],
}),
"ok.md": "# OK",
});
const { indexed, errors } = await indexAllArtifacts(projectDir);
expect(indexed.map((a) => a.name)).toEqual(["ok"]);
expect(errors.map((e) => e.name)).toEqual(["missing"]);
expect(saveCalls[0].map((a) => a.name)).toEqual(["ok"]);
});
it("preserves existing states while checkpointing stale artifacts", async () => {
const projectDir = await createProject({
".socraticodecontextartifacts.json": JSON.stringify({
artifacts: [
{ name: "changed", path: "./changed.md", description: "Changed artifact" },
{ name: "same", path: "./same.md", description: "Same artifact" },
],
}),
"changed.md": "# Changed",
"same.md": "# Same",
});
const { readArtifactContent } = await import("../../src/services/context-artifacts.js");
const same = await readArtifactContent("./same.md", projectDir);
existingMetadata = [
{
name: "changed",
description: "Changed artifact",
resolvedPath: path.join(projectDir, "changed.md"),
contentHash: "stale-hash",
lastIndexedAt: "2026-01-01T00:00:00.000Z",
chunksIndexed: 1,
},
{
name: "same",
description: "Same artifact",
resolvedPath: path.join(projectDir, "same.md"),
contentHash: same.contentHash,
lastIndexedAt: "2026-01-01T00:00:00.000Z",
chunksIndexed: 1,
},
];
const result = await ensureArtifactsIndexed(projectDir);
expect(result.reindexed).toEqual(["changed"]);
expect(result.upToDate).toEqual(["same"]);
expect(saveCalls[0].map((a) => a.name).sort()).toEqual(["changed", "same"]);
});
});