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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user