feat: P4 qdrant collection lifecycle (self-heal + guarded rebuild)
- shared TS collection manager: self-heal creates missing collection (1024/cosine) and missing keyword payload indexes; never mutates incompatible contracts (semantic_index_incompatible); async index visibility polled with bounded deadline - session admission (qdrantEnsure) uses the manager in self-heal mode; operator path keeps require_existing semantics - runtime lease exposes semanticQdrantUrl to the operator - operator commands vector-inspect/vector-rebuild with exact confirmation guards - thothctl workspace vector inspect|rebuild (Go) with --collection/--confirm/--destroy - p4 acceptance runner: real Qdrant (v1.18.2) lifecycle checks, 11/11 PASS - docs: CLI contract, manual walkthrough P4 (PENDING), PROJECT_STATE
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { QDRANT_REQUIRED_INDEXES, reconcileCollection } from "../src/workspaces/qdrant-collection.js";
|
||||
|
||||
function fakeRequest(info: any | undefined, { create = true, index = true } = {}) {
|
||||
let current = info;
|
||||
let created = false;
|
||||
return async (url: string, init?: any) => {
|
||||
if (init?.method === "PUT" && /\/index$/.test(url)) {
|
||||
if (!index) return { status: 409, ok: false, json: async () => ({}) } as any;
|
||||
// Simulate the index being created: the collection becomes fully compatible.
|
||||
current = compatible();
|
||||
return { status: 200, ok: true, json: async () => ({}) } as any;
|
||||
}
|
||||
if (init?.method === "PUT") {
|
||||
if (!create) return { status: 409, ok: false, json: async () => ({}) } as any;
|
||||
created = true;
|
||||
current = compatible();
|
||||
return { status: 200, ok: true, json: async () => ({}) } as any;
|
||||
}
|
||||
if (current === undefined) return { status: 404, ok: false, json: async () => ({}) } as any;
|
||||
return { status: 200, ok: true, json: async () => ({ result: current }) } as any;
|
||||
};
|
||||
}
|
||||
|
||||
const payloadSchema = Object.fromEntries(QDRANT_REQUIRED_INDEXES.map((f) => [f, { data_type: "keyword" }]));
|
||||
const compatible = (size = 1024, distance = "Cosine", schema = payloadSchema) => ({
|
||||
config: { params: { vectors: { size, distance } } },
|
||||
payload_schema: schema,
|
||||
});
|
||||
|
||||
test("self-heal creates a missing compatible collection", async () => {
|
||||
const r = await reconcileCollection({
|
||||
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
|
||||
mode: "self_heal", request: fakeRequest(undefined),
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("require_existing refuses a missing collection", async () => {
|
||||
const r = await reconcileCollection({
|
||||
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
|
||||
mode: "require_existing", request: fakeRequest(undefined),
|
||||
});
|
||||
expect(r).toEqual({ ok: false, code: "semantic_index_incompatible" });
|
||||
});
|
||||
|
||||
test("self-heal adds missing keyword indexes", async () => {
|
||||
const schema = { ...payloadSchema };
|
||||
delete schema["workspace_revision"];
|
||||
const r = await reconcileCollection({
|
||||
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
|
||||
mode: "self_heal", request: fakeRequest(compatible(1024, "Cosine", schema)),
|
||||
});
|
||||
expect(r).toMatchObject({ ok: true, state: "repaired" });
|
||||
});
|
||||
|
||||
test("refuses incompatible dimensions or distance without mutating", async () => {
|
||||
for (const info of [compatible(768, "Cosine"), compatible(1024, "Dot")]) {
|
||||
const r = await reconcileCollection({
|
||||
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
|
||||
mode: "self_heal", request: fakeRequest(info),
|
||||
});
|
||||
expect(r).toEqual({ ok: false, code: "semantic_index_incompatible" });
|
||||
}
|
||||
});
|
||||
|
||||
test("self-heal creates with the Qdrant-valid distance enum", async () => {
|
||||
let createdBody: any;
|
||||
const base = fakeRequest(undefined);
|
||||
const request = async (url: string, init?: any) => {
|
||||
if (init?.method === "PUT" && !/\/index$/.test(url)) createdBody = JSON.parse(String(init.body));
|
||||
return base(url, init);
|
||||
};
|
||||
const r = await reconcileCollection({
|
||||
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
|
||||
mode: "self_heal", request,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(createdBody.vectors.distance).toBe("Cosine");
|
||||
});
|
||||
|
||||
test("ready compatible collection passes", async () => {
|
||||
const r = await reconcileCollection({
|
||||
baseUrl: "http://qdrant:6333", collection: "c", dimensions: 1024, distance: "cosine",
|
||||
mode: "require_existing", request: fakeRequest(compatible()),
|
||||
});
|
||||
expect(r).toMatchObject({ ok: true, state: "ready" });
|
||||
});
|
||||
Reference in New Issue
Block a user