diff --git a/workspace-server/internal/handlers/workspace_restart.go b/workspace-server/internal/handlers/workspace_restart.go index fb677693..c9cbd774 100644 --- a/workspace-server/internal/handlers/workspace_restart.go +++ b/workspace-server/internal/handlers/workspace_restart.go @@ -89,6 +89,27 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) { return } + // runtime=external: the workspace has no Docker container or EC2 — its + // lifecycle is operator-driven (a remote poller heartbeats from outside + // the platform). Pre-fix, this handler still ran the full re-provision + // pipeline, which calls issueAndInjectToken → RevokeAllForWorkspace. + // That silently destroyed the operator's local bearer token on every + // "Restart" click, leaving them with a 401-spamming poller and no + // platform-side recovery path short of regenerating from the canvas + // Tokens tab. Auto-restart already short-circuits external (see + // runRestartCycle below). Mirror that here so manual + automatic + // behavior agree, and surface a clear message instead of silently + // no-op'ing — the canvas can show the operator that the fix is on + // their side. + if dbRuntime == "external" { + c.JSON(http.StatusOK, gin.H{ + "status": "noop", + "runtime": "external", + "message": "external workspaces are operator-driven — restart your local poller; platform has nothing to restart", + }) + return + } + // SaaS mode: cpProv handles workspace EC2 lifecycle. Self-hosted mode: // provisioner handles local Docker containers. At least one must be // available — previously only `provisioner` was checked, which broke diff --git a/workspace-server/internal/handlers/workspace_restart_test.go b/workspace-server/internal/handlers/workspace_restart_test.go index fd5cd4a8..f36b5232 100644 --- a/workspace-server/internal/handlers/workspace_restart_test.go +++ b/workspace-server/internal/handlers/workspace_restart_test.go @@ -124,6 +124,61 @@ func TestRestartHandler_AncestorPausedBlocksRestart(t *testing.T) { } } +func TestRestartHandler_ExternalRuntimeNoOps(t *testing.T) { + // Manual Restart on a runtime=external workspace must short-circuit: + // no Stop, no provision, no token revoke. Pre-fix, this path ran the + // full re-provision pipeline and silently revoked the operator's + // bearer token on every click. + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectQuery("SELECT status, name, tier, COALESCE"). + WithArgs("ws-external"). + WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}). + AddRow("offline", "External Agent", 1, "external")) + + // isParentPaused: no parent + mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id ="). + WithArgs("ws-external"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"})) + + // No further expectations — Restart must NOT touch the DB or the + // provisioner from this point. sqlmock will fail the test if any + // unexpected query runs (UPDATE workspaces SET status=..., the + // RevokeAllForWorkspace DELETE, etc.). + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-external"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-external/restart", nil) + + handler.Restart(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if got, _ := resp["status"].(string); got != "noop" { + t.Errorf("expected status=noop, got %v", resp["status"]) + } + if got, _ := resp["runtime"].(string); got != "external" { + t.Errorf("expected runtime=external, got %v", resp["runtime"]) + } + if msg, _ := resp["message"].(string); !strings.Contains(msg, "operator-driven") { + t.Errorf("expected message about operator-driven, got %v", resp["message"]) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations (or unexpected queries — token revoke or status update would trigger this): %v", err) + } +} + func TestRestartHandler_NilProvisionerReturns503(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t)