diff --git a/docs/INDEX_GENERATIONS.md b/docs/INDEX_GENERATIONS.md index d26da7b..d1813e4 100644 --- a/docs/INDEX_GENERATIONS.md +++ b/docs/INDEX_GENERATIONS.md @@ -105,23 +105,40 @@ content or raw provider responses. ## Current public surface -The HTTP and CLI surfaces are intentionally read-only in this foundation: +The HTTP and CLI surfaces expose read-only status and the full set of lifecycle +mutation routes. Create and activate return `execution_unavailable` (HTTP 503) +because no worker executor yet claims targets and search does not route +generation vectors. Cancel, resume, rollback and discard manage existing builds +but cannot produce a searchable corpus until execution is wired. ```text -GET /v1/workspaces/current/index-generations -GET /v1/workspaces/current/index-generations/{build-id} -GET /v1/workspaces/current/index-generations/{build-id}/events +GET /v1/workspaces/current/index-generations +GET /v1/workspaces/current/index-generations/{build-id} +GET /v1/workspaces/current/index-generations/{build-id}/events + +POST /v1/workspaces/current/index-generations → 503 execution_unavailable +POST /v1/workspaces/current/index-generations/{build-id}/cancel +POST /v1/workspaces/current/index-generations/{build-id}/resume +POST /v1/workspaces/current/index-generations/{build-id}/activate → 503 execution_unavailable +POST /v1/workspaces/current/index-generations/{build-id}/rollback +POST /v1/workspaces/current/index-generations/{build-id}/discard mem generation list mem generation status mem generation events +mem generation create → rejected until execution is wired +mem generation activate → rejected until execution is wired +mem generation cancel +mem generation resume +mem generation rollback +mem generation discard ``` -They expose `execution_wired=false`. The server does not expose create, -activate, rollback, discard, cancel or resume yet. Publishing those mutations -before the Worker and search paths consume the same generation identity would -create a false state where metadata says “active” while queries still use the -released legacy embedding tables. +All responses include `execution_wired: false`. The flag is honest: no code +path executes a build or routes search queries through generation vectors. +Publishing a successful create or activate before the Worker and search paths +consume the same generation identity would create a false state where metadata +says “active” while queries still use the released legacy embedding tables. ## Cost, time and benchmark gate diff --git a/server/internal/api/handlers_index_generations.go b/server/internal/api/handlers_index_generations.go index 1857479..43aa6c5 100644 --- a/server/internal/api/handlers_index_generations.go +++ b/server/internal/api/handlers_index_generations.go @@ -1,7 +1,6 @@ package api import ( - "encoding/json" "errors" "io" "net/http" @@ -37,7 +36,7 @@ func (s *Server) handleListIndexGenerations(w http.ResponseWriter, r *http.Reque } writeJSON(w, http.StatusOK, map[string]any{ "items": builds, - "execution_wired": true, + "execution_wired": false, }) } @@ -60,7 +59,7 @@ func (s *Server) handleGetIndexGeneration(w http.ResponseWriter, r *http.Request } writeJSON(w, http.StatusOK, map[string]any{ "generation": build, - "execution_wired": true, + "execution_wired": false, }) } @@ -85,39 +84,14 @@ func (s *Server) handleListIndexGenerationEvents(w http.ResponseWriter, r *http. } func (s *Server) handleCreateIndexGeneration(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) if s.IndexGenerations == nil { writeError(w, http.StatusServiceUnavailable, "index_generations_disabled", "index generation status is not configured") return } - var req struct { - ProfileID string `json:"profile_id"` - } - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "bad_json", err.Error()) - return - } - if err := requireJSONEOF(decoder); err != nil { - writeError(w, http.StatusBadRequest, "bad_json", err.Error()) - return - } - profileID := strings.TrimSpace(req.ProfileID) - if profileID == "" { - writeError(w, http.StatusBadRequest, "bad_profile_id", "profile_id is required") - return - } - actor := r.Context().Value(ctxActor).(*auth.User) - build, err := s.IndexGenerations.Create(r.Context(), currentWorkspace(r).ID, actor.ID, profileID) - if err != nil { - writeIndexGenerationError(w, err) - return - } - writeJSON(w, http.StatusCreated, map[string]any{ - "generation": build, - "execution_wired": true, - }) + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", + "index generation execution is not wired; no worker processes claimed targets") } func (s *Server) handleCancelIndexGeneration(w http.ResponseWriter, r *http.Request) { @@ -166,7 +140,9 @@ func (s *Server) indexGenerationBuildAction(w http.ResponseWriter, r *http.Reque case "resume": build, err = s.IndexGenerations.Resume(ctx, ws, actor.ID, id) case "activate": - build, err = s.IndexGenerations.Activate(ctx, ws, actor.ID, id) + writeError(w, http.StatusServiceUnavailable, "execution_unavailable", + "index generation execution is not wired; search does not route generation vectors") + return case "rollback": build, err = s.IndexGenerations.Rollback(ctx, ws, actor.ID, id) case "discard": @@ -181,7 +157,7 @@ func (s *Server) indexGenerationBuildAction(w http.ResponseWriter, r *http.Reque } writeJSON(w, http.StatusOK, map[string]any{ "generation": build, - "execution_wired": true, + "execution_wired": false, }) } diff --git a/server/internal/api/handlers_index_generations_test.go b/server/internal/api/handlers_index_generations_test.go index 35a389d..9844049 100644 --- a/server/internal/api/handlers_index_generations_test.go +++ b/server/internal/api/handlers_index_generations_test.go @@ -141,7 +141,7 @@ func TestIndexGenerationStatusHandlersStayWorkspaceScoped(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatal(err) } - if len(response.Items) != 1 || response.Items[0].ID != buildID || !response.ExecutionWired { + if len(response.Items) != 1 || response.Items[0].ID != buildID || response.ExecutionWired { t.Fatalf("response = %#v", response) } }) @@ -195,14 +195,18 @@ func TestIndexGenerationMutationHandlers(t *testing.T) { request := indexGenerationMutationRequest(http.MethodPost, "/v1/workspaces/current/index-generations", workspaceID, actorID, "", body) server.handleCreateIndexGeneration(recorder, request) - if recorder.Code != http.StatusCreated { + if recorder.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String()) } - if service.lastProfile != "local-fast-v2" { - t.Fatalf("profile = %q", service.lastProfile) + var response map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) } - if service.lastActor != actorID { - t.Fatalf("actor = %s, want %s", service.lastActor, actorID) + if response["error"] != "execution_unavailable" { + t.Fatalf("error = %v, want execution_unavailable", response["error"]) + } + if service.lastProfile != "" { + t.Fatalf("service.Create should not have been called, but profile = %q", service.lastProfile) } }) @@ -212,7 +216,7 @@ func TestIndexGenerationMutationHandlers(t *testing.T) { request := indexGenerationMutationRequest(http.MethodPost, "/v1/workspaces/current/index-generations", workspaceID, actorID, "", body) server.handleCreateIndexGeneration(recorder, request) - if recorder.Code != http.StatusBadRequest { + if recorder.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String()) } }) @@ -254,6 +258,67 @@ func TestIndexGenerationMutationHandlers(t *testing.T) { t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String()) } }) + + t.Run("activate_blocked", func(t *testing.T) { + recorder := httptest.NewRecorder() + request := indexGenerationMutationRequest(http.MethodPost, + "/v1/workspaces/current/index-generations/"+buildID.String()+"/activate", + workspaceID, actorID, buildID.String(), nil) + server.handleActivateIndexGeneration(recorder, request) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String()) + } + var response map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response["error"] != "execution_unavailable" { + t.Fatalf("error = %v, want execution_unavailable", response["error"]) + } + if service.lastAction != "" { + t.Fatalf("service.Activate should not have been called, but action = %q", service.lastAction) + } + }) +} + +func TestExecutionWiredFlagMatchesCapability(t *testing.T) { + workspaceID := uuid.New() + actorID := uuid.New() + buildID := uuid.New() + service := &fakeIndexGenerationService{buildID: buildID} + server := &Server{IndexGenerations: service} + + listRecorder := httptest.NewRecorder() + listRequest := indexGenerationRequest(http.MethodGet, + "/v1/workspaces/current/index-generations?limit=25", workspaceID, "") + server.handleListIndexGenerations(listRecorder, listRequest) + var listResponse struct { + ExecutionWired bool `json:"execution_wired"` + } + if err := json.Unmarshal(listRecorder.Body.Bytes(), &listResponse); err != nil { + t.Fatal(err) + } + if listResponse.ExecutionWired { + t.Fatal("execution_wired must be false when no worker executor exists") + } + + createRecorder := httptest.NewRecorder() + createBody := strings.NewReader(`{"profile_id":"local-fast-v2"}`) + createRequest := indexGenerationMutationRequest(http.MethodPost, + "/v1/workspaces/current/index-generations", workspaceID, actorID, "", createBody) + server.handleCreateIndexGeneration(createRecorder, createRequest) + if createRecorder.Code == http.StatusCreated { + t.Fatal("create must not succeed when execution_wired is false") + } + + activateRecorder := httptest.NewRecorder() + activateRequest := indexGenerationMutationRequest(http.MethodPost, + "/v1/workspaces/current/index-generations/"+buildID.String()+"/activate", + workspaceID, actorID, buildID.String(), nil) + server.handleActivateIndexGeneration(activateRecorder, activateRequest) + if activateRecorder.Code == http.StatusOK { + t.Fatal("activate must not succeed when execution_wired is false") + } } func TestIndexGenerationPublicRoutesAreReadOnly(t *testing.T) { diff --git a/server/internal/indexgeneration/types.go b/server/internal/indexgeneration/types.go index 658cece..6a3073c 100644 --- a/server/internal/indexgeneration/types.go +++ b/server/internal/indexgeneration/types.go @@ -41,7 +41,8 @@ var ( ErrInvalidTransition = errors.New("invalid index generation state transition") ErrQualityGate = errors.New("index generation quality gate is not satisfied") ErrDimensionMismatch = errors.New("index generation vector dimension mismatch") - ErrTargetUnavailable = errors.New("index generation target is unavailable") + ErrTargetUnavailable = errors.New("index generation target is unavailable") + ErrExecutionUnavailable = errors.New("index generation execution is not wired") ) // Build is one profile migration. Its route generations activate together so