Skip to content
Merged
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
21 changes: 21 additions & 0 deletions workspace-server/internal/handlers/workspace_restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions workspace-server/internal/handlers/workspace_restart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading