Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions docs/INDEX_GENERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <build-id>
mem generation events <build-id>
mem generation create <profile-id> → rejected until execution is wired
mem generation activate <build-id> → rejected until execution is wired
mem generation cancel <build-id>
mem generation resume <build-id>
mem generation rollback <build-id>
mem generation discard <build-id>
```

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

Expand Down
42 changes: 9 additions & 33 deletions server/internal/api/handlers_index_generations.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package api

import (
"encoding/json"
"errors"
"io"
"net/http"
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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,
})
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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":
Expand All @@ -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,
})
}

Expand Down
79 changes: 72 additions & 7 deletions server/internal/api/handlers_index_generations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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)
}
})

Expand All @@ -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())
}
})
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion server/internal/indexgeneration/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down