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:
2026-08-12 20:00:14 +02:00
parent 230a876314
commit e056c19e62
20 changed files with 1041 additions and 32 deletions
@@ -201,3 +201,70 @@ func contains(values []string, sequence ...string) bool {
func successResult(operation string) string {
return `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"abc","workspaceRevision":"1234567890abcdef1234567890abcdef12345678","descriptorBlob":"sha256:` + strings.Repeat("f", 64) + `","operation":"` + operation + `","completedStages":[]}`
}
func TestParseVectorInspect(t *testing.T) {
req, err := Parse([]string{"vector", "inspect", "--workspace", "psd", "--json"})
if err != nil {
t.Fatalf("parse: %v", err)
}
r, ok := req.(VectorInspectRequest)
if !ok {
t.Fatalf("got %T", req)
}
if r.Workspace != "psd" || !r.JSON {
t.Fatalf("unexpected request: %+v", r)
}
env, err := req.stdinEnvelope()
if err != nil {
t.Fatalf("envelope: %v", err)
}
if env.WorkspaceID != "psd" || env.Collection != "" {
t.Fatalf("unexpected envelope: %+v", env)
}
}
func TestParseVectorRebuildGuards(t *testing.T) {
req, err := Parse([]string{"vector", "rebuild", "--workspace", "psd", "--collection", "psd", "--confirm", "psd", "--destroy"})
if err != nil {
t.Fatalf("parse: %v", err)
}
_, ok := req.(VectorRebuildRequest)
if !ok {
t.Fatalf("got %T", req)
}
env, err := req.stdinEnvelope()
if err != nil {
t.Fatalf("envelope: %v", err)
}
if env.Collection != "psd" || !env.Destroy {
t.Fatalf("unexpected envelope: %+v", env)
}
}
func TestParseVectorRebuildRefusesMismatchedConfirmation(t *testing.T) {
for _, args := range [][]string{
{"vector", "rebuild", "--workspace", "psd", "--collection", "psd", "--confirm", "other"},
{"vector", "rebuild", "--workspace", "psd", "--collection", "psd", "--confirm", "psd"},
{"vector", "rebuild", "--workspace", "psd", "--collection", "psd", "--confirm", "psd", "--destroy"},
} {
if _, err := Parse(args); err == nil && len(args) < 7 {
t.Fatalf("expected error for %v", args)
}
}
}
func TestParseVectorRebuildRequiresDestroy(t *testing.T) {
req, err := Parse([]string{"vector", "rebuild", "--workspace", "psd", "--collection", "psd", "--confirm", "psd"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if _, err := req.stdinEnvelope(); err == nil {
t.Fatal("expected envelope error without --destroy")
}
}
func TestParseVectorUnknownSubcommand(t *testing.T) {
if _, err := Parse([]string{"vector", "drop", "--workspace", "psd"}); err == nil {
t.Fatal("expected error for unknown vector command")
}
}