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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The project publishes 0.x prerelease versions; a stable release line is not yet

## [Unreleased]

### Changed

- Index generation HTTP create/activate/rollback stay `503 execution_unavailable` with `execution_wired: false` on that body, and the same flag is now on events as well as list/status/cancel/resume/discard. Create still returns `400` for malformed JSON or an empty `profile_id` before the 503. Refs #174.

### Added

- Additive `durable-memory.v1` envelope for derived RoleWeave/mem records
Expand Down
43 changes: 34 additions & 9 deletions docs/INDEX_GENERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,23 +105,48 @@ 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 → 503 execution_unavailable
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 rollback <build-id> → rejected until execution is wired
mem generation cancel <build-id>
mem generation resume <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.
Successful list, status, events, cancel, resume and discard responses include
`execution_wired: false`. Create, activate and rollback return
`503 execution_unavailable` with the same flag, and the HTTP handler never
calls `Service.Create`, `Service.Activate` or `Service.Rollback`. Those
service methods remain for in-process tests; they are not reachable over HTTP.
Create still validates JSON (`400 bad_json` / `bad_profile_id`) before the 503
so a malformed body is not retried as a transient outage.
Cancel, resume and discard can mutate existing build metadata but cannot
produce a searchable corpus: the only HTTP writers of `active` state are
activate and rollback, and both are 503.
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
40 changes: 20 additions & 20 deletions server/internal/api/handlers_index_generations.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,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 +60,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 @@ -81,7 +81,10 @@ func (s *Server) handleListIndexGenerationEvents(w http.ResponseWriter, r *http.
writeIndexGenerationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": events})
writeJSON(w, http.StatusOK, map[string]any{
"items": events,
"execution_wired": false,
})
}

func (s *Server) handleCreateIndexGeneration(w http.ResponseWriter, r *http.Request) {
Expand All @@ -103,21 +106,11 @@ func (s *Server) handleCreateIndexGeneration(w http.ResponseWriter, r *http.Requ
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
profileID := strings.TrimSpace(req.ProfileID)
if profileID == "" {
if strings.TrimSpace(req.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,
})
writeExecutionUnavailable(w, "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 @@ -165,10 +158,9 @@ func (s *Server) indexGenerationBuildAction(w http.ResponseWriter, r *http.Reque
build, err = s.IndexGenerations.Cancel(ctx, ws, actor.ID, id)
case "resume":
build, err = s.IndexGenerations.Resume(ctx, ws, actor.ID, id)
case "activate":
build, err = s.IndexGenerations.Activate(ctx, ws, actor.ID, id)
case "rollback":
build, err = s.IndexGenerations.Rollback(ctx, ws, actor.ID, id)
case "activate", "rollback":
writeExecutionUnavailable(w, "index generation execution is not wired; search does not route generation vectors")
return
case "discard":
build, err = s.IndexGenerations.Discard(ctx, ws, actor.ID, id)
default:
Expand All @@ -181,14 +173,22 @@ 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,
})
}

func indexGenerationBuildID(r *http.Request) (uuid.UUID, error) {
return uuid.Parse(strings.TrimSpace(chi.URLParam(r, "buildID")))
}

func writeExecutionUnavailable(w http.ResponseWriter, hint string) {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"error": "execution_unavailable",
"hint": hint,
"execution_wired": false,
})
}

func writeIndexGenerationError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, indexgeneration.ErrNotFound):
Expand Down
136 changes: 129 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,21 @@ 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 response["error"] != "execution_unavailable" {
t.Fatalf("error = %v, want execution_unavailable", response["error"])
}
if service.lastActor != actorID {
t.Fatalf("actor = %s, want %s", service.lastActor, actorID)
if response["execution_wired"] != false {
t.Fatalf("execution_wired = %v, want false", response["execution_wired"])
}
if service.lastProfile != "" {
t.Fatalf("service.Create should not have been called, but profile = %q", service.lastProfile)
}
})

Expand All @@ -217,6 +224,17 @@ func TestIndexGenerationMutationHandlers(t *testing.T) {
}
})

t.Run("create_rejects_malformed_body", func(t *testing.T) {
recorder := httptest.NewRecorder()
body := strings.NewReader(`{{{{`)
request := indexGenerationMutationRequest(http.MethodPost,
"/v1/workspaces/current/index-generations", workspaceID, actorID, "", body)
server.handleCreateIndexGeneration(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String())
}
})

t.Run("cancel", func(t *testing.T) {
recorder := httptest.NewRecorder()
request := indexGenerationMutationRequest(http.MethodPost,
Expand Down Expand Up @@ -254,9 +272,113 @@ func TestIndexGenerationMutationHandlers(t *testing.T) {
t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String())
}
})

t.Run("activate_blocked", func(t *testing.T) {
service.lastAction = ""
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 response["execution_wired"] != false {
t.Fatalf("execution_wired = %v, want false", response["execution_wired"])
}
if service.lastAction != "" {
t.Fatalf("service.Activate should not have been called, but action = %q", service.lastAction)
}
})

t.Run("rollback_blocked", func(t *testing.T) {
service.lastAction = ""
recorder := httptest.NewRecorder()
request := indexGenerationMutationRequest(http.MethodPost,
"/v1/workspaces/current/index-generations/"+buildID.String()+"/rollback",
workspaceID, actorID, buildID.String(), nil)
server.handleRollbackIndexGeneration(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 response["execution_wired"] != false {
t.Fatalf("execution_wired = %v, want false", response["execution_wired"])
}
if service.lastAction != "" {
t.Fatalf("service.Rollback 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")
}

eventsRecorder := httptest.NewRecorder()
eventsRequest := indexGenerationRequest(http.MethodGet,
"/v1/workspaces/current/index-generations/"+buildID.String()+"/events", workspaceID, buildID.String())
server.handleListIndexGenerationEvents(eventsRecorder, eventsRequest)
var eventsResponse struct {
ExecutionWired bool `json:"execution_wired"`
}
if err := json.Unmarshal(eventsRecorder.Body.Bytes(), &eventsResponse); err != nil {
t.Fatal(err)
}
if eventsResponse.ExecutionWired {
t.Fatal("events execution_wired must be false")
}

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) {
func TestIndexGenerationPublicRoutesAreRegistered(t *testing.T) {
routes, ok := (&Server{}).Router().(chi.Routes)
if !ok {
t.Fatal("Server.Router did not return chi.Routes")
Expand Down
Loading