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
@@ -161,6 +161,9 @@ type requestEnvelope struct {
SQLFiles []inputFile `json:"fromSql,omitempty"`
Annotations string `json:"annotationsYaml,omitempty"`
ReviewedCandidates string `json:"reviewedCandidatesDigest,omitempty"`
Collection string `json:"collection,omitempty"`
Confirm string `json:"confirm,omitempty"`
Destroy bool `json:"destroy,omitempty"`
}
type inputFile struct {
@@ -257,11 +260,27 @@ func Parse(args []string) (Request, error) {
return nil, err
}
return parsed, nil
case "vector":
return parseVector(args[1:])
default:
return nil, fmt.Errorf("unknown workspace command %q", args[0])
}
}
func parseVector(args []string) (Request, error) {
if len(args) == 0 {
return nil, errors.New("vector requires a subcommand")
}
switch args[0] {
case "inspect":
return parseVectorInspect(args[1:])
case "rebuild":
return parseVectorRebuild(args[1:])
default:
return nil, fmt.Errorf("unknown vector command %q", args[0])
}
}
func Execute(ctx context.Context, installation config.Installation, runner Runner, request Request) (Result, error) {
envelope, err := request.stdinEnvelope()
if err != nil {
@@ -798,3 +817,71 @@ func Human(result Result) string {
}
return strings.Join(lines, "\n") + "\n"
}
// VectorInspectRequest reads the Qdrant collection contract without mutation.
type VectorInspectRequest struct{ baseRequest }
// VectorRebuildRequest deletes and recreates the descriptor-owned collection under guards.
type VectorRebuildRequest struct {
baseRequest
Collection string
Confirm string
Destroy bool
}
func (VectorInspectRequest) workspaceRequest() {}
func (VectorRebuildRequest) workspaceRequest() {}
func (VectorInspectRequest) operatorCommand() string { return "vector-inspect" }
func (VectorRebuildRequest) operatorCommand() string { return "vector-rebuild" }
func (r VectorInspectRequest) stdinEnvelope() (requestEnvelope, error) {
return requestEnvelope{SchemaVersion: 1, WorkspaceID: r.Workspace}, nil
}
func (r VectorRebuildRequest) stdinEnvelope() (requestEnvelope, error) {
if r.Collection == "" {
return requestEnvelope{}, errors.New("--collection is required")
}
if r.Confirm == "" {
return requestEnvelope{}, errors.New("--confirm is required and must equal --collection")
}
if r.Confirm != r.Collection {
return requestEnvelope{}, errors.New("--confirm must equal --collection")
}
if !r.Destroy {
return requestEnvelope{}, errors.New("--destroy is required to confirm the destructive rebuild")
}
return requestEnvelope{
SchemaVersion: 1,
WorkspaceID: r.Workspace,
Collection: r.Collection,
Confirm: r.Confirm,
Destroy: r.Destroy,
}, nil
}
func parseVectorInspect(args []string) (Request, error) {
base, err := parseBaseFlags(args, false)
if err != nil {
return nil, err
}
return VectorInspectRequest{baseRequest: base}, nil
}
func parseVectorRebuild(args []string) (Request, error) {
request := VectorRebuildRequest{}
values := map[string]func(string) error{
"--collection": func(v string) error { request.Collection = v; return nil },
"--confirm": func(v string) error { request.Confirm = v; return nil },
}
bools := map[string]func() error{
"--destroy": func() error { request.Destroy = true; return nil },
}
base, seen, err := parseSharedFlags(args, values, bools)
if err != nil {
return nil, err
}
if !seen.workspace {
return nil, errors.New("--workspace is required")
}
request.baseRequest = base
return request, nil
}
@@ -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")
}
}