diff --git a/workspace-server/internal/handlers/registry.go b/workspace-server/internal/handlers/registry.go index 3e4bca7d..28ea327e 100644 --- a/workspace-server/internal/handlers/registry.go +++ b/workspace-server/internal/handlers/registry.go @@ -898,10 +898,18 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) { // flip, independent of evaluateStatus. evaluateStatus still owns the OTHER // recovery transitions (offline/degraded/awaiting_agent/failed→online), // which the inline CASE does not touch. + // + // IMPORTANT (enum scan): `status` is a NOT-NULL `workspace_status` ENUM. + // Do NOT wrap it in COALESCE(status, '') — the '' literal is coerced to + // the enum type and Postgres rejects it with `invalid input value for + // enum workspace_status: ""`, failing the WHOLE row scan. That left + // prevStatus = "" on every heartbeat, so the prevStatus=='provisioning' + // reconcile trigger NEVER fired (the #32 regression returned). Select the + // column bare; it is never NULL. var prevTask string var prevSpend int64 var prevStatus string - if err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(current_task, ''), COALESCE(monthly_spend, 0), COALESCE(status, '') FROM workspaces WHERE id = $1`, payload.WorkspaceID).Scan(&prevTask, &prevSpend, &prevStatus); err != nil { + if err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(current_task, ''), COALESCE(monthly_spend, 0), status FROM workspaces WHERE id = $1`, payload.WorkspaceID).Scan(&prevTask, &prevSpend, &prevStatus); err != nil { log.Printf("registry heartbeat: prev_task query failed for workspace %s: %v", payload.WorkspaceID, err) } diff --git a/workspace-server/internal/handlers/registry_test.go b/workspace-server/internal/handlers/registry_test.go index 5104d92b..43bbde06 100644 --- a/workspace-server/internal/handlers/registry_test.go +++ b/workspace-server/internal/handlers/registry_test.go @@ -271,7 +271,14 @@ func TestHeartbeatHandler_ProvisioningToOnline(t *testing.T) { // prevTask + prevStatus SELECT — prevStatus='provisioning' is the state a // freshly-created workspace is in before its first heartbeat. - mock.ExpectQuery("SELECT COALESCE\\(current_task"). + // + // The matcher pins `status` selected BARE (not COALESCE-wrapped): `status` + // is a NOT-NULL workspace_status ENUM, and COALESCE(status, '') coerces '' + // to the enum → Postgres `invalid input value for enum workspace_status: ""` + // → the whole row scan fails → prevStatus stays "" → this reconcile trigger + // NEVER fires (the live #32 regression). Requiring `, status FROM workspaces` + // here makes a re-introduced COALESCE(status, ...) fail this unit test. + mock.ExpectQuery("SELECT COALESCE\\(current_task, ''\\), COALESCE\\(monthly_spend, 0\\), status FROM workspaces"). WithArgs("ws-provisioning"). WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "provisioning"))