From fed6352b584e2a85c93e99a4ff778c80afe976ed Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sat, 23 May 2026 00:33:15 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(workspace-server):=20#1686=20Phase=201?= =?UTF-8?q?=20=E2=80=94=20compute=20schema=20(instance=5Ftype=20+=20volume?= =?UTF-8?q?.root=5Fgb)=20in=20Create=20+=20provisioner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: add compute_instance_type (TEXT) and compute_volume_root_gb (INTEGER) to workspaces table with IF NOT EXISTS guards. - Models: ComputeConfig + ComputeVolume structs, ValidateComputeConfig with bounds (instance_type max 64, root_gb 32–2048). - Handler (Create): validate compute block, extract nullable overrides, pass them into the INSERT (14 args now). - Provisioner config: add InstanceType + VolumeRootGB to WorkspaceConfig. - CP provisioner: include instance_type + volume_root_gb in cpProvisionRequest JSON body with omitempty (nil = CP default). - Tests: • handler tests: updated all sqlmock INSERT WithArgs for 14 args, added TestWorkspaceCreate_InvalidCompute and TestWorkspaceCreate_WithComputeOverrides. • workspace_provision_test: added TestBuildProvisionerConfig_ComputeOverrides and TestBuildProvisionerConfig_ComputeNil. • cp_provisioner_test: added TestStart_ComputeOverrides and TestStart_ComputeOmittedWhenNil. • models: new workspace_compute_test.go covering nil, empty, valid, and boundary validation. Backward-compatible: omitted compute block = nil columns = platform-managed defaults (no change to existing behaviour). Co-Authored-By: Claude Opus 4.7 --- .../handlers/handlers_additional_test.go | 6 +- .../internal/handlers/handlers_test.go | 2 +- .../internal/handlers/workspace.go | 22 +++- .../handlers/workspace_budget_test.go | 2 + .../internal/handlers/workspace_provision.go | 22 ++++ .../handlers/workspace_provision_test.go | 69 +++++++++++ .../internal/handlers/workspace_test.go | 111 ++++++++++++++++-- workspace-server/internal/models/workspace.go | 42 +++++++ .../internal/models/workspace_compute_test.go | 90 ++++++++++++++ .../internal/provisioner/cp_provisioner.go | 20 ++-- .../provisioner/cp_provisioner_test.go | 72 ++++++++++++ .../internal/provisioner/provisioner.go | 5 + .../20260523000000_workspace_compute.down.sql | 5 + .../20260523000000_workspace_compute.up.sql | 10 ++ 14 files changed, 453 insertions(+), 25 deletions(-) create mode 100644 workspace-server/internal/models/workspace_compute_test.go create mode 100644 workspace-server/migrations/20260523000000_workspace_compute.down.sql create mode 100644 workspace-server/migrations/20260523000000_workspace_compute.up.sql diff --git a/workspace-server/internal/handlers/handlers_additional_test.go b/workspace-server/internal/handlers/handlers_additional_test.go index 0e13600d5..fc24f3fb6 100644 --- a/workspace-server/internal/handlers/handlers_additional_test.go +++ b/workspace-server/internal/handlers/handlers_additional_test.go @@ -33,7 +33,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) { // Default tier is 3 (Privileged) — see workspace.go create-handler comment. // delivery_mode defaults to "push" when payload omits it (#2339). mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -69,7 +69,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) { mock.ExpectBegin() // delivery_mode defaults to "push" when payload omits it (#2339). mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -291,7 +291,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push"). + WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). diff --git a/workspace-server/internal/handlers/handlers_test.go b/workspace-server/internal/handlers/handlers_test.go index 7ce01b239..58ee8d942 100644 --- a/workspace-server/internal/handlers/handlers_test.go +++ b/workspace-server/internal/handlers/handlers_test.go @@ -368,7 +368,7 @@ func TestWorkspaceCreate(t *testing.T) { // Default tier is 3 (Privileged) — see workspace.go create-handler comment. // delivery_mode defaults to "push" when payload omits it (#2339). mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) // Expect transaction commit (no secrets in this payload) diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index c89622fde..93b918409 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -214,6 +214,11 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace fields"}) return } + // #1686 Phase 1: validate per-workspace compute overrides. + if err := models.ValidateComputeConfig(payload.Compute); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } id := uuid.New().String() awarenessNamespace := workspaceAwarenessNamespace(id) @@ -398,11 +403,22 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // double-click. Helper retries with " (2)", " (3)", … up to maxNameSuffix, // returns the actually-persisted name (which we MUST thread back into // payload + broadcast so the canvas displays what the DB has). + var computeInstanceType *string + var computeVolumeRootGB *int + if payload.Compute != nil { + if payload.Compute.InstanceType != "" { + computeInstanceType = &payload.Compute.InstanceType + } + if payload.Compute.Volume.RootGB != 0 { + computeVolumeRootGB = &payload.Compute.Volume.RootGB + } + } + const insertWorkspaceSQL = ` - INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode) - VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12) + INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode, compute_instance_type, compute_volume_root_gb) + VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12, $13, $14) ` - insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode} + insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode, computeInstanceType, computeVolumeRootGB} persistedName, currentTx, err := insertWorkspaceWithNameRetry( ctx, tx, diff --git a/workspace-server/internal/handlers/workspace_budget_test.go b/workspace-server/internal/handlers/workspace_budget_test.go index 4652e2932..bb1e87715 100644 --- a/workspace-server/internal/handlers/workspace_budget_test.go +++ b/workspace-server/internal/handlers/workspace_budget_test.go @@ -157,6 +157,8 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) { &budgetVal, // budget_limit ($10) models.DefaultMaxConcurrentTasks, // max_concurrent_tasks default "push", // delivery_mode default (#2339) + (*string)(nil), // compute_instance_type default + (*int)(nil), // compute_volume_root_gb default ). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() diff --git a/workspace-server/internal/handlers/workspace_provision.go b/workspace-server/internal/handlers/workspace_provision.go index 11da5f448..3a5774951 100644 --- a/workspace-server/internal/handlers/workspace_provision.go +++ b/workspace-server/internal/handlers/workspace_provision.go @@ -309,9 +309,31 @@ func (h *WorkspaceHandler) buildProvisionerConfig( // RuntimeImages[Runtime] :latest lookup, which is what the dead // reader's sql.ErrNoRows path was producing already. Image: "", + // Compute overrides (nullable — omitted = platform-managed default). + // Issue #1686 Phase 1. + InstanceType: extractComputeInstanceType(payload.Compute), + VolumeRootGB: extractComputeVolumeRootGB(payload.Compute), } } +// extractComputeInstanceType returns the instance type from a ComputeConfig, +// or nil when cfg is nil or the field is empty. +func extractComputeInstanceType(cfg *models.ComputeConfig) *string { + if cfg != nil && cfg.InstanceType != "" { + return &cfg.InstanceType + } + return nil +} + +// extractComputeVolumeRootGB returns the root volume size from a ComputeConfig, +// or nil when cfg is nil or the field is zero. +func extractComputeVolumeRootGB(cfg *models.ComputeConfig) *int { + if cfg != nil && cfg.Volume.RootGB != 0 { + return &cfg.Volume.RootGB + } + return nil +} + // issueAndInjectToken rotates the workspace auth token and injects the // plaintext into cfg.ConfigFiles[".auth_token"] so it is written into the // /configs volume by WriteFilesToContainer immediately after the container diff --git a/workspace-server/internal/handlers/workspace_provision_test.go b/workspace-server/internal/handlers/workspace_provision_test.go index a5e46d64a..f00983a49 100644 --- a/workspace-server/internal/handlers/workspace_provision_test.go +++ b/workspace-server/internal/handlers/workspace_provision_test.go @@ -779,6 +779,75 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) { } } +// TestBuildProvisionerConfig_ComputeOverrides verifies that #1686 Phase 1 +// compute fields (instance_type + volume.root_gb) are threaded from the +// create payload into the provisioner config. +func TestBuildProvisionerConfig_ComputeOverrides(t *testing.T) { + mock := setupTestDB(t) + mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`). + WithArgs("ws-compute"). + WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none")) + + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + + cfg := handler.buildProvisionerConfig( + context.Background(), + "ws-compute", + "", + nil, + models.CreateWorkspacePayload{ + Tier: 2, + Runtime: "python", + Compute: &models.ComputeConfig{ + InstanceType: "g4dn.xlarge", + Volume: models.ComputeVolume{RootGB: 256}, + }, + }, + nil, + "", + "workspace:ws-compute", + ) + + if cfg.InstanceType == nil || *cfg.InstanceType != "g4dn.xlarge" { + t.Errorf("InstanceType = %v, want g4dn.xlarge", cfg.InstanceType) + } + if cfg.VolumeRootGB == nil || *cfg.VolumeRootGB != 256 { + t.Errorf("VolumeRootGB = %v, want 256", cfg.VolumeRootGB) + } +} + +// TestBuildProvisionerConfig_ComputeNil verifies backward compat: when the +// payload omits compute, the provisioner config fields are nil so the CP +// applies its own defaults. +func TestBuildProvisionerConfig_ComputeNil(t *testing.T) { + mock := setupTestDB(t) + mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`). + WithArgs("ws-no-compute"). + WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none")) + + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + + cfg := handler.buildProvisionerConfig( + context.Background(), + "ws-no-compute", + "", + nil, + models.CreateWorkspacePayload{Tier: 1, Runtime: "python"}, + nil, + "", + "workspace:ws-no-compute", + ) + + if cfg.InstanceType != nil { + t.Errorf("InstanceType = %v, want nil", cfg.InstanceType) + } + if cfg.VolumeRootGB != nil { + t.Errorf("VolumeRootGB = %v, want nil", cfg.VolumeRootGB) + } +} + // ==================== issueAndInjectToken (issue #418) ==================== // TestIssueAndInjectToken_HappyPath verifies that on a normal (re)provision the diff --git a/workspace-server/internal/handlers/workspace_test.go b/workspace-server/internal/handlers/workspace_test.go index 7f329da2e..7704e57ea 100644 --- a/workspace-server/internal/handlers/workspace_test.go +++ b/workspace-server/internal/handlers/workspace_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -342,7 +343,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) { // Transaction begins, workspace INSERT fails, transaction is rolled back. mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnError(sql.ErrConnDone) mock.ExpectRollback() @@ -364,6 +365,94 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) { } } +// TestWorkspaceCreate_InvalidCompute verifies #1686 Phase 1 create-time +// validation: bad instance_type or volume.root_gb returns 400 before any +// DB call. +func TestWorkspaceCreate_InvalidCompute(t *testing.T) { + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + + cases := []struct { + name string + body string + want string + }{ + { + name: "instance_type too long", + body: `{"name":"Bad Type","compute":{"instance_type":"` + strings.Repeat("x", 65) + `"}}`, + want: "compute.instance_type too long", + }, + { + name: "root_gb too small", + body: `{"name":"Small Disk","compute":{"volume":{"root_gb":16}}}`, + want: "compute.volume.root_gb must be at least 32", + }, + { + name: "root_gb too large", + body: `{"name":"Big Disk","compute":{"volume":{"root_gb":4096}}}`, + want: "compute.volume.root_gb exceeds maximum 2048", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Create(c) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), tc.want) { + t.Errorf("body %q should contain %q", w.Body.String(), tc.want) + } + }) + } +} + +// TestWorkspaceCreate_WithComputeOverrides verifies that valid #1686 Phase 1 +// compute fields are persisted into the workspaces table. +func TestWorkspaceCreate_WithComputeOverrides(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectBegin() + instanceType := "g4dn.xlarge" + rootGB := 256 + mock.ExpectExec("INSERT INTO workspaces"). + WithArgs(sqlmock.AnyArg(), "GPU Agent", nil, 3, "python", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", &instanceType, &rootGB). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + mock.ExpectExec("INSERT INTO canvas_layouts"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO structure_events"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`UPDATE workspaces SET status =`). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO workspace_auth_tokens"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO structure_events"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `{"name":"GPU Agent","runtime":"python","compute":{"instance_type":"g4dn.xlarge","volume":{"root_gb":256}}}` + c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Create(c) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + func TestWorkspaceCreate_DefaultsApplied(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) @@ -375,7 +464,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) { // Expect workspace INSERT with defaulted tier=3 (Privileged — the // handler default in workspace.go), runtime="langgraph" mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() @@ -423,7 +512,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -464,7 +553,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) // Secret inserted inside the same transaction. mock.ExpectExec("INSERT INTO workspace_secrets"). @@ -576,7 +665,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() // External URL update (localhost is explicitly allowed by validateAgentURL). @@ -615,7 +704,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() // Pre-register flow: awaiting_agent + runtime preserved as "kimi" @@ -1639,7 +1728,7 @@ runtime_config: mock.ExpectExec("INSERT INTO workspaces"). WithArgs( sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", - sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1696,7 +1785,7 @@ model: anthropic:claude-sonnet-4-5 mock.ExpectExec("INSERT INTO workspaces"). WithArgs( sqlmock.AnyArg(), "Legacy Agent", nil, 3, "langgraph", - sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1749,7 +1838,7 @@ runtime_config: mock.ExpectExec("INSERT INTO workspaces"). WithArgs( sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes", - sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1855,7 +1944,7 @@ func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testi mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1890,7 +1979,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). diff --git a/workspace-server/internal/models/workspace.go b/workspace-server/internal/models/workspace.go index 9139fc5b9..c1f608a5f 100644 --- a/workspace-server/internal/models/workspace.go +++ b/workspace-server/internal/models/workspace.go @@ -3,6 +3,7 @@ package models import ( "database/sql" "encoding/json" + "fmt" "time" ) @@ -45,6 +46,10 @@ type Workspace struct { // forced to route updates through a parent workspace. Default true // (preserves existing behaviour for all workspaces). TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"` + // Compute overrides (nullable — omitted = platform-managed default). + // Issue #1686 Phase 1. + ComputeInstanceType *string `json:"compute_instance_type,omitempty" db:"compute_instance_type"` + ComputeVolumeRootGB *int `json:"compute_volume_root_gb,omitempty" db:"compute_volume_root_gb"` // Canvas layout fields (from JOIN) X float64 `json:"x"` Y float64 `json:"y"` @@ -154,6 +159,40 @@ type MemorySeed struct { Scope string `json:"scope" yaml:"scope"` // LOCAL, TEAM, GLOBAL } +// ComputeVolume holds per-workspace disk configuration. +type ComputeVolume struct { + RootGB int `json:"root_gb"` +} + +// ComputeConfig holds per-workspace EC2 compute overrides. +// Omitted at create time means "use platform-managed defaults". +type ComputeConfig struct { + InstanceType string `json:"instance_type"` + Volume ComputeVolume `json:"volume"` +} + +// ValidateComputeConfig performs create-time validation on compute overrides. +// Returns nil when cfg is nil (omitted = platform-managed default). +func ValidateComputeConfig(cfg *ComputeConfig) error { + if cfg == nil { + return nil + } + if cfg.InstanceType != "" { + if len(cfg.InstanceType) > 64 { + return fmt.Errorf("compute.instance_type too long (max 64 chars)") + } + } + if cfg.Volume.RootGB != 0 { + if cfg.Volume.RootGB < 32 { + return fmt.Errorf("compute.volume.root_gb must be at least 32") + } + if cfg.Volume.RootGB > 2048 { + return fmt.Errorf("compute.volume.root_gb exceeds maximum 2048") + } + } + return nil +} + type CreateWorkspacePayload struct { Name string `json:"name" binding:"required"` Role string `json:"role"` @@ -180,6 +219,9 @@ type CreateWorkspacePayload struct { // MaxConcurrentTasks caps parallel A2A + cron dispatch. 0 means use // DefaultMaxConcurrentTasks. Leaders typically set 3. MaxConcurrentTasks int `json:"max_concurrent_tasks"` + // Compute is an optional per-workspace EC2 shape override. + // Omitted = platform-managed default (current behaviour). + Compute *ComputeConfig `json:"compute,omitempty"` Canvas struct { X float64 `json:"x"` Y float64 `json:"y"` diff --git a/workspace-server/internal/models/workspace_compute_test.go b/workspace-server/internal/models/workspace_compute_test.go new file mode 100644 index 000000000..86b4aa5d8 --- /dev/null +++ b/workspace-server/internal/models/workspace_compute_test.go @@ -0,0 +1,90 @@ +package models + +import "testing" + +func TestValidateComputeConfig_NilIsValid(t *testing.T) { + if err := ValidateComputeConfig(nil); err != nil { + t.Errorf("nil compute config should be valid, got: %v", err) + } +} + +func TestValidateComputeConfig_EmptyIsValid(t *testing.T) { + cfg := &ComputeConfig{} + if err := ValidateComputeConfig(cfg); err != nil { + t.Errorf("empty compute config should be valid, got: %v", err) + } +} + +func TestValidateComputeConfig_ValidOverrides(t *testing.T) { + cfg := &ComputeConfig{ + InstanceType: "g4dn.xlarge", + Volume: ComputeVolume{RootGB: 256}, + } + if err := ValidateComputeConfig(cfg); err != nil { + t.Errorf("valid overrides should pass, got: %v", err) + } +} + +func TestValidateComputeConfig_InstanceTypeTooLong(t *testing.T) { + longName := string(make([]byte, 65)) + for i := range longName { + longName = longName[:i] + "x" + longName[i+1:] + } + cfg := &ComputeConfig{InstanceType: longName} + if err := ValidateComputeConfig(cfg); err == nil { + t.Error("expected error for instance_type > 64 chars") + } else if err.Error() != "compute.instance_type too long (max 64 chars)" { + t.Errorf("unexpected error message: %q", err.Error()) + } +} + +func TestValidateComputeConfig_RootGBTooSmall(t *testing.T) { + cfg := &ComputeConfig{Volume: ComputeVolume{RootGB: 31}} + if err := ValidateComputeConfig(cfg); err == nil { + t.Error("expected error for root_gb < 32") + } else if err.Error() != "compute.volume.root_gb must be at least 32" { + t.Errorf("unexpected error message: %q", err.Error()) + } +} + +func TestValidateComputeConfig_RootGBTooLarge(t *testing.T) { + cfg := &ComputeConfig{Volume: ComputeVolume{RootGB: 2049}} + if err := ValidateComputeConfig(cfg); err == nil { + t.Error("expected error for root_gb > 2048") + } else if err.Error() != "compute.volume.root_gb exceeds maximum 2048" { + t.Errorf("unexpected error message: %q", err.Error()) + } +} + +func TestValidateComputeConfig_BoundaryValues(t *testing.T) { + cases := []struct { + name string + cfg ComputeConfig + ok bool + }{ + {"min root_gb", ComputeConfig{Volume: ComputeVolume{RootGB: 32}}, true}, + {"max root_gb", ComputeConfig{Volume: ComputeVolume{RootGB: 2048}}, true}, + {"just under min", ComputeConfig{Volume: ComputeVolume{RootGB: 31}}, false}, + {"just over max", ComputeConfig{Volume: ComputeVolume{RootGB: 2049}}, false}, + {"exactly 64 char type", ComputeConfig{InstanceType: string(make([]byte, 64))}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // fill the 64-char case with 'x' + if tc.cfg.InstanceType != "" { + b := make([]byte, len(tc.cfg.InstanceType)) + for i := range b { + b[i] = 'x' + } + tc.cfg.InstanceType = string(b) + } + err := ValidateComputeConfig(&tc.cfg) + if tc.ok && err != nil { + t.Errorf("expected valid, got: %v", err) + } + if !tc.ok && err == nil { + t.Error("expected invalid, got nil") + } + }) + } +} diff --git a/workspace-server/internal/provisioner/cp_provisioner.go b/workspace-server/internal/provisioner/cp_provisioner.go index 8f6f0c557..2c44bcd45 100644 --- a/workspace-server/internal/provisioner/cp_provisioner.go +++ b/workspace-server/internal/provisioner/cp_provisioner.go @@ -163,6 +163,10 @@ type cpProvisionRequest struct { // collectCPConfigFiles which rejects symlinks and non-regular files // before including them. Serialised as base64 to avoid JSON escaping. ConfigFiles map[string]string `json:"config_files,omitempty"` + // Compute overrides (nullable — omitted = platform-managed default). + // Issue #1686 Phase 1. + InstanceType *string `json:"instance_type,omitempty"` + VolumeRootGB *int `json:"volume_root_gb,omitempty"` } type cpProvisionResponse struct { @@ -206,13 +210,15 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, } req := cpProvisionRequest{ - OrgID: p.orgID, - WorkspaceID: cfg.WorkspaceID, - Runtime: cfg.Runtime, - Tier: cfg.Tier, - PlatformURL: cfg.PlatformURL, - Env: env, - ConfigFiles: configFiles, + OrgID: p.orgID, + WorkspaceID: cfg.WorkspaceID, + Runtime: cfg.Runtime, + Tier: cfg.Tier, + PlatformURL: cfg.PlatformURL, + Env: env, + ConfigFiles: configFiles, + InstanceType: cfg.InstanceType, + VolumeRootGB: cfg.VolumeRootGB, } body, err := json.Marshal(req) diff --git a/workspace-server/internal/provisioner/cp_provisioner_test.go b/workspace-server/internal/provisioner/cp_provisioner_test.go index 6f1ea07e8..6561cc84e 100644 --- a/workspace-server/internal/provisioner/cp_provisioner_test.go +++ b/workspace-server/internal/provisioner/cp_provisioner_test.go @@ -1062,3 +1062,75 @@ func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) { t.Errorf("expected symlink-related error, got: %v", err) } } + +// TestStart_ComputeOverrides — when WorkspaceConfig carries InstanceType and +// VolumeRootGB, they must be forwarded in the cpProvisionRequest body so the +// CP can pass them to EC2 RunInstances. Regression guard for #1686 Phase 1. +func TestStart_ComputeOverrides(t *testing.T) { + var gotBody cpProvisionRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode request: %v", err) + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"instance_id":"i-compute","state":"pending"}`) + })) + defer srv.Close() + + p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()} + instanceType := "g4dn.xlarge" + volumeRootGB := 256 + _, err := p.Start(context.Background(), WorkspaceConfig{ + WorkspaceID: "ws-1", + Runtime: "python", + Tier: 2, + PlatformURL: "http://tenant", + InstanceType: &instanceType, + VolumeRootGB: &volumeRootGB, + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + if gotBody.InstanceType == nil || *gotBody.InstanceType != "g4dn.xlarge" { + t.Errorf("instance_type = %v, want g4dn.xlarge", gotBody.InstanceType) + } + if gotBody.VolumeRootGB == nil || *gotBody.VolumeRootGB != 256 { + t.Errorf("volume_root_gb = %v, want 256", gotBody.VolumeRootGB) + } +} + +// TestStart_ComputeOmittedWhenNil — when WorkspaceConfig has no compute +// overrides, the JSON body must omit the keys entirely (omitempty) so CP +// applies its own defaults rather than empty/zero values. +func TestStart_ComputeOmittedWhenNil(t *testing.T) { + var raw json.RawMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + t.Errorf("decode request: %v", err) + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"instance_id":"i-default","state":"pending"}`) + })) + defer srv.Close() + + p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()} + _, err := p.Start(context.Background(), WorkspaceConfig{ + WorkspaceID: "ws-1", + Runtime: "python", + Tier: 1, + PlatformURL: "http://tenant", + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal raw body: %v", err) + } + if _, ok := decoded["instance_type"]; ok { + t.Errorf("instance_type should be omitted when nil") + } + if _, ok := decoded["volume_root_gb"]; ok { + t.Errorf("volume_root_gb should be omitted when nil") + } +} diff --git a/workspace-server/internal/provisioner/provisioner.go b/workspace-server/internal/provisioner/provisioner.go index 164c951bf..c12cf5b11 100644 --- a/workspace-server/internal/provisioner/provisioner.go +++ b/workspace-server/internal/provisioner/provisioner.go @@ -105,6 +105,11 @@ type WorkspaceConfig struct { WorkspaceAccess string // #65: "none" (default), "read_only", or "read_write" ResetClaudeSession bool // #12: if true, discard the claude-sessions volume before start (fresh session dir) + // Compute overrides (nullable — omitted = platform-managed default). + // Issue #1686 Phase 1. + InstanceType *string `json:"instance_type,omitempty"` + VolumeRootGB *int `json:"volume_root_gb,omitempty"` + // Image, when non-empty, overrides the runtime→image lookup. CP // (molecule-controlplane) is the single SSOT for runtime image digest // pins via its migrations/027_runtime_image_pins table — the pin is diff --git a/workspace-server/migrations/20260523000000_workspace_compute.down.sql b/workspace-server/migrations/20260523000000_workspace_compute.down.sql new file mode 100644 index 000000000..4ffa6c4d5 --- /dev/null +++ b/workspace-server/migrations/20260523000000_workspace_compute.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE workspaces + DROP COLUMN IF EXISTS compute_instance_type; + +ALTER TABLE workspaces + DROP COLUMN IF EXISTS compute_volume_root_gb; diff --git a/workspace-server/migrations/20260523000000_workspace_compute.up.sql b/workspace-server/migrations/20260523000000_workspace_compute.up.sql new file mode 100644 index 000000000..28dfef749 --- /dev/null +++ b/workspace-server/migrations/20260523000000_workspace_compute.up.sql @@ -0,0 +1,10 @@ +-- Per-workspace EC2 compute configuration (#1686 Phase 1). +-- Allows callers to override instance_type and root volume size +-- at workspace creation time. Omitted/null values preserve the +-- platform-managed default (current behaviour), so this is fully +-- backwards-compatible. +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS compute_instance_type TEXT; + +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS compute_volume_root_gb INTEGER; -- 2.52.0 From 1362b6dd01e9ef694d8293fbafac236287bb6cd8 Mon Sep 17 00:00:00 2001 From: fullstack-engineer Date: Fri, 22 May 2026 17:44:24 -0700 Subject: [PATCH 2/2] Align compute sizing with JSONB contract --- .../handlers/handlers_additional_test.go | 14 +- .../internal/handlers/handlers_test.go | 14 +- .../internal/handlers/workspace.go | 58 +++--- .../handlers/workspace_budget_test.go | 32 ++-- .../internal/handlers/workspace_compute.go | 128 +++++++++++++ .../handlers/workspace_compute_test.go | 175 ++++++++++++++++++ .../internal/handlers/workspace_provision.go | 25 +-- .../handlers/workspace_provision_test.go | 69 ------- .../internal/handlers/workspace_restart.go | 8 +- .../internal/handlers/workspace_test.go | 137 +++----------- workspace-server/internal/models/workspace.go | 83 ++++----- .../internal/models/workspace_compute_test.go | 90 --------- .../internal/provisioner/cp_provisioner.go | 22 +-- .../provisioner/cp_provisioner_test.go | 83 +-------- .../internal/provisioner/provisioner.go | 8 +- .../migrations/050_workspace_compute.down.sql | 1 + .../migrations/050_workspace_compute.up.sql | 2 + .../20260523000000_workspace_compute.down.sql | 5 - .../20260523000000_workspace_compute.up.sql | 10 - 19 files changed, 454 insertions(+), 510 deletions(-) create mode 100644 workspace-server/internal/handlers/workspace_compute.go create mode 100644 workspace-server/internal/handlers/workspace_compute_test.go delete mode 100644 workspace-server/internal/models/workspace_compute_test.go create mode 100644 workspace-server/migrations/050_workspace_compute.down.sql create mode 100644 workspace-server/migrations/050_workspace_compute.up.sql delete mode 100644 workspace-server/migrations/20260523000000_workspace_compute.down.sql delete mode 100644 workspace-server/migrations/20260523000000_workspace_compute.up.sql diff --git a/workspace-server/internal/handlers/handlers_additional_test.go b/workspace-server/internal/handlers/handlers_additional_test.go index fc24f3fb6..98aa736d8 100644 --- a/workspace-server/internal/handlers/handlers_additional_test.go +++ b/workspace-server/internal/handlers/handlers_additional_test.go @@ -33,7 +33,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) { // Default tier is 3 (Privileged) — see workspace.go create-handler comment. // delivery_mode defaults to "push" when payload omits it (#2339). mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -69,7 +69,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) { mock.ExpectBegin() // delivery_mode defaults to "push" when payload omits it (#2339). mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -230,7 +230,7 @@ func TestWorkspaceList_WithData(t *testing.T) { broadcaster := newTestBroadcaster() handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) - // 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend + // 24 cols — compute added after talk_to_user_enabled. // (migration 20260514). Column order must match scanWorkspaceRow exactly. columns := []string{ "id", "name", "role", "tier", "status", "agent_card", "url", @@ -238,13 +238,13 @@ func TestWorkspaceList_WithData(t *testing.T) { "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } rows := sqlmock.NewRows(columns). AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001", - nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true). + nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)). AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "", - nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true) + nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`)) mock.ExpectQuery("SELECT w.id, w.name"). WillReturnRows(rows) @@ -291,7 +291,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). diff --git a/workspace-server/internal/handlers/handlers_test.go b/workspace-server/internal/handlers/handlers_test.go index 58ee8d942..d14a249de 100644 --- a/workspace-server/internal/handlers/handlers_test.go +++ b/workspace-server/internal/handlers/handlers_test.go @@ -368,7 +368,7 @@ func TestWorkspaceCreate(t *testing.T) { // Default tier is 3 (Privileged) — see workspace.go create-handler comment. // delivery_mode defaults to "push" when payload omits it (#2339). mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) // Expect transaction commit (no secrets in this payload) @@ -456,7 +456,7 @@ func TestWorkspaceList(t *testing.T) { broadcaster := newTestBroadcaster() handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs") - // 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend + // 24 cols: compute added after talk_to_user_enabled. // (migration 20260514). Column order must match scanWorkspaceRow exactly. columns := []string{ "id", "name", "role", "tier", "status", "agent_card", "url", @@ -464,13 +464,13 @@ func TestWorkspaceList(t *testing.T) { "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } rows := sqlmock.NewRows(columns). AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001", - nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true). + nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)). AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "", - nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true) + nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`)) mock.ExpectQuery("SELECT w.id, w.name"). WillReturnRows(rows) @@ -1184,14 +1184,14 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) { "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } mock.ExpectQuery("SELECT w.id, w.name"). WithArgs("dddddddd-0004-0000-0000-000000000000"). WillReturnRows(sqlmock.NewRows(columns).AddRow( "dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000", nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false, - nil, int64(0), false, true, + nil, int64(0), false, true, []byte(`{}`), )) w := httptest.NewRecorder() diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index 93b918409..e36af77aa 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -214,11 +214,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace fields"}) return } - // #1686 Phase 1: validate per-workspace compute overrides. - if err := models.ValidateComputeConfig(payload.Compute); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } id := uuid.New().String() awarenessNamespace := workspaceAwarenessNamespace(id) @@ -353,6 +348,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace access"}) return } + if err := validateWorkspaceCompute(payload.Compute); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } // Begin a transaction so the workspace row and any initial secrets are // committed atomically. A secret-encrypt or DB error rolls back the @@ -403,22 +402,11 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // double-click. Helper retries with " (2)", " (3)", … up to maxNameSuffix, // returns the actually-persisted name (which we MUST thread back into // payload + broadcast so the canvas displays what the DB has). - var computeInstanceType *string - var computeVolumeRootGB *int - if payload.Compute != nil { - if payload.Compute.InstanceType != "" { - computeInstanceType = &payload.Compute.InstanceType - } - if payload.Compute.Volume.RootGB != 0 { - computeVolumeRootGB = &payload.Compute.Volume.RootGB - } - } - const insertWorkspaceSQL = ` - INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode, compute_instance_type, compute_volume_root_gb) - VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12, $13, $14) + INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode) + VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12) ` - insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode, computeInstanceType, computeVolumeRootGB} + insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode} persistedName, currentTx, err := insertWorkspaceWithNameRetry( ctx, tx, @@ -451,6 +439,24 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { payload.Name = persistedName } + if !workspaceComputeIsZero(payload.Compute) { + computeJSON, encErr := workspaceComputeJSON(payload.Compute) + if encErr != nil { + tx.Rollback() //nolint:errcheck + log.Printf("Create workspace %s: failed to encode compute config: %v", id, encErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode compute config"}) + return + } + if _, dbErr := tx.ExecContext(ctx, + `UPDATE workspaces SET compute = $2::jsonb, updated_at = now() WHERE id = $1`, + id, computeJSON); dbErr != nil { + tx.Rollback() //nolint:errcheck + log.Printf("Create workspace %s: failed to persist compute config: %v", id, dbErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save compute config"}) + return + } + } + // Persist initial secrets from the create payload (inside same transaction). // nil/empty map is a no-op. Any failure rolls back the workspace insert // so we never have a workspace row without its intended secrets. @@ -695,6 +701,7 @@ func scanWorkspaceRow(rows interface { Scan(dest ...interface{}) error }) (map[string]interface{}, error) { var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string + var computeRaw []byte var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int var errorRate, x, y float64 var collapsed, broadcastEnabled, talkToUserEnabled bool @@ -706,7 +713,7 @@ func scanWorkspaceRow(rows interface { err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url, &parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds, ¤tTask, &runtime, &workspaceDir, &x, &y, &collapsed, - &budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled) + &budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw) if err != nil { return nil, err } @@ -733,6 +740,11 @@ func scanWorkspaceRow(rows interface { "broadcast_enabled": broadcastEnabled, "talk_to_user_enabled": talkToUserEnabled, } + if len(computeRaw) > 0 && string(computeRaw) != "null" { + ws["compute"] = json.RawMessage(computeRaw) + } else { + ws["compute"] = json.RawMessage(`{}`) + } // budget_limit: nil when no limit set, int64 otherwise if budgetLimit.Valid { @@ -768,7 +780,8 @@ const workspaceListQuery = ` COALESCE(w.workspace_dir, ''), COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false), w.budget_limit, COALESCE(w.monthly_spend, 0), - w.broadcast_enabled, w.talk_to_user_enabled + w.broadcast_enabled, w.talk_to_user_enabled, + COALESCE(w.compute, '{}'::jsonb) FROM workspaces w LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id WHERE w.status != 'removed' @@ -829,7 +842,8 @@ func (h *WorkspaceHandler) Get(c *gin.Context) { COALESCE(w.workspace_dir, ''), COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false), w.budget_limit, COALESCE(w.monthly_spend, 0), - w.broadcast_enabled, w.talk_to_user_enabled + w.broadcast_enabled, w.talk_to_user_enabled, + COALESCE(w.compute, '{}'::jsonb) FROM workspaces w LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id WHERE w.id = $1 diff --git a/workspace-server/internal/handlers/workspace_budget_test.go b/workspace-server/internal/handlers/workspace_budget_test.go index bb1e87715..67fd77237 100644 --- a/workspace-server/internal/handlers/workspace_budget_test.go +++ b/workspace-server/internal/handlers/workspace_budget_test.go @@ -33,7 +33,7 @@ var wsColumns = []string{ "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } // ==================== GET — financial fields stripped from open endpoint ==================== @@ -56,7 +56,8 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) { nil, // budget_limit NULL 0, // monthly_spend 0 false, // broadcast_enabled - true)) // talk_to_user_enabled + true, // talk_to_user_enabled + []byte(`{}`))) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -100,7 +101,8 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) { 0.0, 0.0, false, int64(500), // budget_limit = $5.00 in DB int64(123), // monthly_spend = $1.23 in DB - false, true)) // broadcast_enabled, talk_to_user_enabled + false, true, // broadcast_enabled, talk_to_user_enabled + []byte(`{}`))) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -145,20 +147,18 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). WithArgs( - sqlmock.AnyArg(), // id - "Budgeted Agent", // name - nil, // role - 3, // tier (default, workspace.go create-handler) - "langgraph", // runtime - sqlmock.AnyArg(), // awareness_namespace - (*string)(nil), // parent_id - nil, // workspace_dir - "none", // workspace_access - &budgetVal, // budget_limit ($10) + sqlmock.AnyArg(), // id + "Budgeted Agent", // name + nil, // role + 3, // tier (default, workspace.go create-handler) + "langgraph", // runtime + sqlmock.AnyArg(), // awareness_namespace + (*string)(nil), // parent_id + nil, // workspace_dir + "none", // workspace_access + &budgetVal, // budget_limit ($10) models.DefaultMaxConcurrentTasks, // max_concurrent_tasks default - "push", // delivery_mode default (#2339) - (*string)(nil), // compute_instance_type default - (*int)(nil), // compute_volume_root_gb default + "push", // delivery_mode default (#2339) ). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() diff --git a/workspace-server/internal/handlers/workspace_compute.go b/workspace-server/internal/handlers/workspace_compute.go new file mode 100644 index 000000000..58522f955 --- /dev/null +++ b/workspace-server/internal/handlers/workspace_compute.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/models" +) + +const ( + workspaceComputeDiskFloorGB = 30 + workspaceComputeDiskCeilingGB = 500 +) + +var workspaceComputeInstanceAllowlist = map[string]struct{}{ + "t3.medium": {}, + "t3.large": {}, + "t3.xlarge": {}, + "t3.2xlarge": {}, + "m6i.large": {}, + "m6i.xlarge": {}, + "c6i.xlarge": {}, +} + +func validateWorkspaceCompute(compute models.WorkspaceCompute) error { + if compute.InstanceType != "" { + if _, ok := workspaceComputeInstanceAllowlist[compute.InstanceType]; !ok { + return fmt.Errorf("unsupported compute.instance_type") + } + } + if compute.Volume.RootGB != 0 { + if compute.Volume.RootGB < workspaceComputeDiskFloorGB || compute.Volume.RootGB > workspaceComputeDiskCeilingGB { + return fmt.Errorf("compute.volume.root_gb must be between %d and %d", workspaceComputeDiskFloorGB, workspaceComputeDiskCeilingGB) + } + } + switch compute.Display.Mode { + case "", "none", "desktop-control", "gpu-desktop-control": + default: + return fmt.Errorf("unsupported compute.display.mode") + } + switch compute.Display.Protocol { + case "", "dcv": + default: + return fmt.Errorf("unsupported compute.display.protocol") + } + if compute.Display.Width < 0 || compute.Display.Height < 0 { + return fmt.Errorf("compute.display width/height must be non-negative") + } + return nil +} + +func workspaceComputeIsZero(compute models.WorkspaceCompute) bool { + return compute.InstanceType == "" && + compute.Volume.RootGB == 0 && + compute.Display.Mode == "" && + compute.Display.Width == 0 && + compute.Display.Height == 0 && + compute.Display.Protocol == "" +} + +func workspaceComputeJSON(compute models.WorkspaceCompute) (string, error) { + if workspaceComputeIsZero(compute) { + return "{}", nil + } + out := map[string]interface{}{} + if compute.InstanceType != "" { + out["instance_type"] = compute.InstanceType + } + if compute.Volume.RootGB != 0 { + out["volume"] = map[string]interface{}{"root_gb": compute.Volume.RootGB} + } + display := map[string]interface{}{} + if compute.Display.Mode != "" { + display["mode"] = compute.Display.Mode + } + if compute.Display.Width != 0 { + display["width"] = compute.Display.Width + } + if compute.Display.Height != 0 { + display["height"] = compute.Display.Height + } + if compute.Display.Protocol != "" { + display["protocol"] = compute.Display.Protocol + } + if len(display) > 0 { + out["display"] = display + } + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +func withStoredCompute(ctx context.Context, workspaceID string, payload models.CreateWorkspacePayload) models.CreateWorkspacePayload { + if !workspaceComputeIsZero(payload.Compute) || db.DB == nil { + return payload + } + var raw string + err := db.DB.QueryRowContext(ctx, + `SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`, + workspaceID, + ).Scan(&raw) + if err != nil { + if err != sql.ErrNoRows { + log.Printf("withStoredCompute: load compute for %s failed: %v", workspaceID, err) + } + return payload + } + if raw == "" || raw == "{}" { + return payload + } + var compute models.WorkspaceCompute + if err := json.Unmarshal([]byte(raw), &compute); err != nil { + log.Printf("withStoredCompute: invalid compute JSON for %s: %v", workspaceID, err) + return payload + } + if err := validateWorkspaceCompute(compute); err != nil { + log.Printf("withStoredCompute: stored compute for %s failed validation: %v", workspaceID, err) + return payload + } + payload.Compute = compute + return payload +} diff --git a/workspace-server/internal/handlers/workspace_compute_test.go b/workspace-server/internal/handlers/workspace_compute_test.go new file mode 100644 index 000000000..f82c4262a --- /dev/null +++ b/workspace-server/internal/handlers/workspace_compute_test.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/models" + "github.com/gin-gonic/gin" +) + +func TestValidateWorkspaceCompute_AcceptsPhase1SizingAndDisplayNone(t *testing.T) { + compute := models.WorkspaceCompute{ + InstanceType: "m6i.xlarge", + Volume: models.WorkspaceComputeVolume{RootGB: 100}, + Display: models.WorkspaceComputeDisplay{Mode: "none"}, + } + + if err := validateWorkspaceCompute(compute); err != nil { + t.Fatalf("validateWorkspaceCompute returned error for valid compute: %v", err) + } +} + +func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) { + compute := models.WorkspaceCompute{InstanceType: "p4d.24xlarge"} + + if err := validateWorkspaceCompute(compute); err == nil { + t.Fatal("validateWorkspaceCompute accepted unsupported instance type") + } +} + +func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) { + for _, rootGB := range []int{29, 501} { + compute := models.WorkspaceCompute{Volume: models.WorkspaceComputeVolume{RootGB: rootGB}} + if err := validateWorkspaceCompute(compute); err == nil { + t.Fatalf("validateWorkspaceCompute accepted root_gb=%d", rootGB) + } + } +} + +func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) { + got, err := workspaceComputeJSON(models.WorkspaceCompute{ + InstanceType: "m6i.xlarge", + Volume: models.WorkspaceComputeVolume{RootGB: 100}, + }) + if err != nil { + t.Fatalf("workspaceComputeJSON returned error: %v", err) + } + + if strings.Contains(got, `"display"`) { + t.Fatalf("workspaceComputeJSON included empty display section: %s", got) + } + if got != `{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}` { + t.Fatalf("workspaceComputeJSON = %s", got) + } +} + +func TestWorkspaceCreate_WithCompute_PersistsComputeJSON(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO workspaces"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`UPDATE workspaces SET compute = \$2::jsonb`). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectExec("INSERT INTO canvas_layouts"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `{ + "name":"Sized Agent", + "external":true, + "runtime":"external", + "compute":{ + "instance_type":"m6i.xlarge", + "volume":{"root_gb":100}, + "display":{"mode":"none"} + } + }` + c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Create(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `{ + "name":"Oversized Agent", + "compute":{"instance_type":"p4d.24xlarge"} + }` + c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Create(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) { + mock := setupTestDB(t) + mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`). + WithArgs("ws-compute"). + WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none")) + + handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir()) + cfg := handler.buildProvisionerConfig( + context.Background(), + "ws-compute", + "", + nil, + models.CreateWorkspacePayload{ + Tier: 4, + Runtime: "claude-code", + Compute: models.WorkspaceCompute{ + InstanceType: "m6i.xlarge", + Volume: models.WorkspaceComputeVolume{RootGB: 100}, + }, + }, + nil, + t.TempDir(), + "workspace:ws-compute", + ) + + if cfg.InstanceType != "m6i.xlarge" { + t.Errorf("cfg.InstanceType = %q, want m6i.xlarge", cfg.InstanceType) + } + if cfg.DiskGB != 100 { + t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB) + } +} + +func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) { + mock := setupTestDB(t) + mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`). + WithArgs("ws-restart-compute"). + WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}`)) + + payload := models.CreateWorkspacePayload{Name: "Restart Me", Tier: 4, Runtime: "claude-code"} + got := withStoredCompute(context.Background(), "ws-restart-compute", payload) + + if got.Compute.InstanceType != "m6i.xlarge" { + t.Errorf("stored compute instance_type = %q, want m6i.xlarge", got.Compute.InstanceType) + } + if got.Compute.Volume.RootGB != 100 { + t.Errorf("stored compute root_gb = %d, want 100", got.Compute.Volume.RootGB) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} diff --git a/workspace-server/internal/handlers/workspace_provision.go b/workspace-server/internal/handlers/workspace_provision.go index 3a5774951..2ba89a740 100644 --- a/workspace-server/internal/handlers/workspace_provision.go +++ b/workspace-server/internal/handlers/workspace_provision.go @@ -296,6 +296,8 @@ func (h *WorkspaceHandler) buildProvisionerConfig( WorkspaceAccess: workspaceAccess, Tier: payload.Tier, Runtime: payload.Runtime, + InstanceType: payload.Compute.InstanceType, + DiskGB: int32(payload.Compute.Volume.RootGB), EnvVars: envVars, PlatformURL: h.platformURL, AwarenessURL: os.Getenv("AWARENESS_URL"), @@ -309,31 +311,9 @@ func (h *WorkspaceHandler) buildProvisionerConfig( // RuntimeImages[Runtime] :latest lookup, which is what the dead // reader's sql.ErrNoRows path was producing already. Image: "", - // Compute overrides (nullable — omitted = platform-managed default). - // Issue #1686 Phase 1. - InstanceType: extractComputeInstanceType(payload.Compute), - VolumeRootGB: extractComputeVolumeRootGB(payload.Compute), } } -// extractComputeInstanceType returns the instance type from a ComputeConfig, -// or nil when cfg is nil or the field is empty. -func extractComputeInstanceType(cfg *models.ComputeConfig) *string { - if cfg != nil && cfg.InstanceType != "" { - return &cfg.InstanceType - } - return nil -} - -// extractComputeVolumeRootGB returns the root volume size from a ComputeConfig, -// or nil when cfg is nil or the field is zero. -func extractComputeVolumeRootGB(cfg *models.ComputeConfig) *int { - if cfg != nil && cfg.Volume.RootGB != 0 { - return &cfg.Volume.RootGB - } - return nil -} - // issueAndInjectToken rotates the workspace auth token and injects the // plaintext into cfg.ConfigFiles[".auth_token"] so it is written into the // /configs volume by WriteFilesToContainer immediately after the container @@ -1031,4 +1011,3 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID) } - diff --git a/workspace-server/internal/handlers/workspace_provision_test.go b/workspace-server/internal/handlers/workspace_provision_test.go index f00983a49..a5e46d64a 100644 --- a/workspace-server/internal/handlers/workspace_provision_test.go +++ b/workspace-server/internal/handlers/workspace_provision_test.go @@ -779,75 +779,6 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) { } } -// TestBuildProvisionerConfig_ComputeOverrides verifies that #1686 Phase 1 -// compute fields (instance_type + volume.root_gb) are threaded from the -// create payload into the provisioner config. -func TestBuildProvisionerConfig_ComputeOverrides(t *testing.T) { - mock := setupTestDB(t) - mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`). - WithArgs("ws-compute"). - WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none")) - - broadcaster := newTestBroadcaster() - handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) - - cfg := handler.buildProvisionerConfig( - context.Background(), - "ws-compute", - "", - nil, - models.CreateWorkspacePayload{ - Tier: 2, - Runtime: "python", - Compute: &models.ComputeConfig{ - InstanceType: "g4dn.xlarge", - Volume: models.ComputeVolume{RootGB: 256}, - }, - }, - nil, - "", - "workspace:ws-compute", - ) - - if cfg.InstanceType == nil || *cfg.InstanceType != "g4dn.xlarge" { - t.Errorf("InstanceType = %v, want g4dn.xlarge", cfg.InstanceType) - } - if cfg.VolumeRootGB == nil || *cfg.VolumeRootGB != 256 { - t.Errorf("VolumeRootGB = %v, want 256", cfg.VolumeRootGB) - } -} - -// TestBuildProvisionerConfig_ComputeNil verifies backward compat: when the -// payload omits compute, the provisioner config fields are nil so the CP -// applies its own defaults. -func TestBuildProvisionerConfig_ComputeNil(t *testing.T) { - mock := setupTestDB(t) - mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`). - WithArgs("ws-no-compute"). - WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none")) - - broadcaster := newTestBroadcaster() - handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) - - cfg := handler.buildProvisionerConfig( - context.Background(), - "ws-no-compute", - "", - nil, - models.CreateWorkspacePayload{Tier: 1, Runtime: "python"}, - nil, - "", - "workspace:ws-no-compute", - ) - - if cfg.InstanceType != nil { - t.Errorf("InstanceType = %v, want nil", cfg.InstanceType) - } - if cfg.VolumeRootGB != nil { - t.Errorf("VolumeRootGB = %v, want nil", cfg.VolumeRootGB) - } -} - // ==================== issueAndInjectToken (issue #418) ==================== // TestIssueAndInjectToken_HappyPath verifies that on a normal (re)provision the diff --git a/workspace-server/internal/handlers/workspace_restart.go b/workspace-server/internal/handlers/workspace_restart.go index 6eb490913..6ac3dc506 100644 --- a/workspace-server/internal/handlers/workspace_restart.go +++ b/workspace-server/internal/handlers/workspace_restart.go @@ -164,7 +164,7 @@ func (h *WorkspaceHandler) maybeRestartAfterFileWrite(workspaceID string) { // isRestarting reports whether a restart cycle is currently in flight for // the workspace. Callers that have their own "container looks dead" probe // MUST consult this before triggering a restart, because during the -// 20-30s EC2-pending window the workspace's url='' and IsRunning()=false +// 20-30s EC2-pending window the workspace's url=” and IsRunning()=false // looks identical to a dead container — and any restart-triggering probe // (maybeMarkContainerDead from canvas /delegations poll, or the trailing // restart-context probe at the end of runRestartCycle) will set @@ -337,7 +337,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) { } var configFiles map[string][]byte - payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime} + payload := withStoredCompute(ctx, id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime}) log.Printf("Restart: workspace %s (%s) runtime=%q", wsName, id, containerRuntime) // #12: ?reset=true (or body.Reset) discards the claude-sessions volume @@ -791,7 +791,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) { }) // Runtime from DB — no more config file parsing - payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime} + payload := withStoredCompute(ctx, workspaceID, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime}) // Snapshot restart-context data before the new session overwrites // last_heartbeat_at. Issue #19 Layer 1. @@ -948,7 +948,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) { h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{ "name": ws.name, "tier": ws.tier, "runtime": ws.runtime, }) - payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime} + payload := withStoredCompute(ctx, ws.id, models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime}) // Resume is provision-only (workspace is paused, no live container // to stop). provisionWorkspaceAuto handles backend routing and the // no-backend mark-failed fallback identically to Create. Pre- diff --git a/workspace-server/internal/handlers/workspace_test.go b/workspace-server/internal/handlers/workspace_test.go index 7704e57ea..e8deeb475 100644 --- a/workspace-server/internal/handlers/workspace_test.go +++ b/workspace-server/internal/handlers/workspace_test.go @@ -8,7 +8,6 @@ import ( "net/http/httptest" "os" "path/filepath" - "strings" "testing" "time" @@ -30,7 +29,7 @@ func TestWorkspaceGet_Success(t *testing.T) { "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } mock.ExpectQuery("SELECT w.id, w.name"). WithArgs("cccccccc-0001-0000-0000-000000000000"). @@ -38,7 +37,7 @@ func TestWorkspaceGet_Success(t *testing.T) { AddRow("cccccccc-0001-0000-0000-000000000000", "My Agent", "worker", 1, "online", []byte(`{"name":"test"}`), "http://localhost:8001", nil, 2, 1, 0.05, "", 3600, "working", "langgraph", "", 10.0, 20.0, false, - nil, 0, false, true)) + nil, 0, false, true, []byte(`{}`))) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -120,7 +119,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) { "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } mock.ExpectQuery("SELECT w.id, w.name"). WithArgs(id). @@ -128,7 +127,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) { AddRow(id, "Old Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`), "", nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 0.0, 0.0, false, - nil, 0, false, true)) + nil, 0, false, true, []byte(`{}`))) mock.ExpectQuery(`SELECT updated_at FROM workspaces`). WithArgs(id). WillReturnRows(sqlmock.NewRows([]string{"updated_at"}).AddRow(removedAt)) @@ -184,7 +183,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure( "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } mock.ExpectQuery("SELECT w.id, w.name"). WithArgs(id). @@ -192,7 +191,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure( AddRow(id, "Vanished", "worker", 1, string(models.StatusRemoved), []byte(`null`), "", nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 0.0, 0.0, false, - nil, 0, false, true)) + nil, 0, false, true, []byte(`{}`))) // Simulate the row vanishing between the two queries. mock.ExpectQuery(`SELECT updated_at FROM workspaces`). WithArgs(id). @@ -247,7 +246,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) { "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } mock.ExpectQuery("SELECT w.id, w.name"). WithArgs(id). @@ -255,7 +254,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) { AddRow(id, "Audit Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`), "", nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 0.0, 0.0, false, - nil, 0, false, true)) + nil, 0, false, true, []byte(`{}`))) // last_outbound_at follow-up query (existing path) mock.ExpectQuery(`SELECT last_outbound_at FROM workspaces`). WithArgs(id). @@ -343,7 +342,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) { // Transaction begins, workspace INSERT fails, transaction is rolled back. mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnError(sql.ErrConnDone) mock.ExpectRollback() @@ -365,94 +364,6 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) { } } -// TestWorkspaceCreate_InvalidCompute verifies #1686 Phase 1 create-time -// validation: bad instance_type or volume.root_gb returns 400 before any -// DB call. -func TestWorkspaceCreate_InvalidCompute(t *testing.T) { - broadcaster := newTestBroadcaster() - handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) - - cases := []struct { - name string - body string - want string - }{ - { - name: "instance_type too long", - body: `{"name":"Bad Type","compute":{"instance_type":"` + strings.Repeat("x", 65) + `"}}`, - want: "compute.instance_type too long", - }, - { - name: "root_gb too small", - body: `{"name":"Small Disk","compute":{"volume":{"root_gb":16}}}`, - want: "compute.volume.root_gb must be at least 32", - }, - { - name: "root_gb too large", - body: `{"name":"Big Disk","compute":{"volume":{"root_gb":4096}}}`, - want: "compute.volume.root_gb exceeds maximum 2048", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.Create(c) - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), tc.want) { - t.Errorf("body %q should contain %q", w.Body.String(), tc.want) - } - }) - } -} - -// TestWorkspaceCreate_WithComputeOverrides verifies that valid #1686 Phase 1 -// compute fields are persisted into the workspaces table. -func TestWorkspaceCreate_WithComputeOverrides(t *testing.T) { - mock := setupTestDB(t) - setupTestRedis(t) - broadcaster := newTestBroadcaster() - handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()) - - mock.ExpectBegin() - instanceType := "g4dn.xlarge" - rootGB := 256 - mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "GPU Agent", nil, 3, "python", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", &instanceType, &rootGB). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectCommit() - - mock.ExpectExec("INSERT INTO canvas_layouts"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("INSERT INTO structure_events"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`UPDATE workspaces SET status =`). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("INSERT INTO workspace_auth_tokens"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("INSERT INTO structure_events"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - body := `{"name":"GPU Agent","runtime":"python","compute":{"instance_type":"g4dn.xlarge","volume":{"root_gb":256}}}` - c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.Create(c) - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet sqlmock expectations: %v", err) - } -} - func TestWorkspaceCreate_DefaultsApplied(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) @@ -464,7 +375,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) { // Expect workspace INSERT with defaulted tier=3 (Privileged — the // handler default in workspace.go), runtime="langgraph" mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() @@ -512,7 +423,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -553,7 +464,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) // Secret inserted inside the same transaction. mock.ExpectExec("INSERT INTO workspace_secrets"). @@ -665,7 +576,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() // External URL update (localhost is explicitly allowed by validateAgentURL). @@ -704,7 +615,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() // Pre-register flow: awaiting_agent + runtime preserved as "kimi" @@ -807,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) { "parent_id", "active_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", })) w := httptest.NewRecorder() @@ -1511,7 +1422,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) { "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } // Populate with non-zero financial values to confirm they are stripped. mock.ExpectQuery("SELECT w.id, w.name"). @@ -1520,7 +1431,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) { AddRow("cccccccc-0010-0000-0000-000000000000", "Finance Test", "worker", 1, "online", []byte(`{}`), "http://localhost:9001", nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 0.0, 0.0, false, - int64(50000), int64(12500), false, true)) // budget_limit=500 USD, spend=125 USD + int64(50000), int64(12500), false, true, []byte(`{}`))) // budget_limit=500 USD, spend=125 USD w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1568,7 +1479,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) { "parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error", "uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed", "budget_limit", "monthly_spend", - "broadcast_enabled", "talk_to_user_enabled", + "broadcast_enabled", "talk_to_user_enabled", "compute", } mock.ExpectQuery("SELECT w.id, w.name"). WithArgs("cccccccc-0955-0000-0000-000000000000"). @@ -1581,7 +1492,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) { "langgraph", "/home/user/secret-projects/client-work", 0.0, 0.0, false, - nil, 0, false, true)) + nil, 0, false, true, []byte(`{}`))) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1728,7 +1639,7 @@ runtime_config: mock.ExpectExec("INSERT INTO workspaces"). WithArgs( sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", - sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1785,7 +1696,7 @@ model: anthropic:claude-sonnet-4-5 mock.ExpectExec("INSERT INTO workspaces"). WithArgs( sqlmock.AnyArg(), "Legacy Agent", nil, 3, "langgraph", - sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1838,7 +1749,7 @@ runtime_config: mock.ExpectExec("INSERT INTO workspaces"). WithArgs( sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes", - sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1944,7 +1855,7 @@ func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testi mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1979,7 +1890,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push", (*string)(nil), (*int)(nil)). + WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). diff --git a/workspace-server/internal/models/workspace.go b/workspace-server/internal/models/workspace.go index c1f608a5f..ecdc7a023 100644 --- a/workspace-server/internal/models/workspace.go +++ b/workspace-server/internal/models/workspace.go @@ -3,7 +3,6 @@ package models import ( "database/sql" "encoding/json" - "fmt" "time" ) @@ -36,20 +35,16 @@ type Workspace struct { // DeliveryMode: "push" (synchronous to URL — default) or "poll" (logged // to activity_logs, agent reads via GET /activity?since_id=). See // migration 045 + RFC #2339. - DeliveryMode string `json:"delivery_mode" db:"delivery_mode"` + DeliveryMode string `json:"delivery_mode" db:"delivery_mode"` // BroadcastEnabled: when true the workspace may call POST /broadcast to // deliver a message to all non-removed agent workspaces in the org. // Default false — only privileged orchestrators should hold this ability. - BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"` + BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"` // TalkToUserEnabled: when false the workspace's send_message_to_user calls // and POST /notify requests are rejected with HTTP 403 so the agent is // forced to route updates through a parent workspace. Default true // (preserves existing behaviour for all workspaces). - TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"` - // Compute overrides (nullable — omitted = platform-managed default). - // Issue #1686 Phase 1. - ComputeInstanceType *string `json:"compute_instance_type,omitempty" db:"compute_instance_type"` - ComputeVolumeRootGB *int `json:"compute_volume_root_gb,omitempty" db:"compute_volume_root_gb"` + TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"` // Canvas layout fields (from JOIN) X float64 `json:"x"` Y float64 `json:"y"` @@ -76,12 +71,12 @@ type RegisterPayload struct { // enforces the conditional requirement based on the resolved // delivery mode (payload value, falling back to the row's existing // value, falling back to "push"). - URL string `json:"url"` - AgentCard json.RawMessage `json:"agent_card" binding:"required"` + URL string `json:"url"` + AgentCard json.RawMessage `json:"agent_card" binding:"required"` // DeliveryMode is optional. Empty string means "keep the existing // value on the workspace row, or default to push for new rows". // When set, must be one of DeliveryModePush / DeliveryModePoll. - DeliveryMode string `json:"delivery_mode,omitempty"` + DeliveryMode string `json:"delivery_mode,omitempty"` } type HeartbeatPayload struct { @@ -159,53 +154,36 @@ type MemorySeed struct { Scope string `json:"scope" yaml:"scope"` // LOCAL, TEAM, GLOBAL } -// ComputeVolume holds per-workspace disk configuration. -type ComputeVolume struct { - RootGB int `json:"root_gb"` +type WorkspaceComputeVolume struct { + RootGB int `json:"root_gb,omitempty"` } -// ComputeConfig holds per-workspace EC2 compute overrides. -// Omitted at create time means "use platform-managed defaults". -type ComputeConfig struct { - InstanceType string `json:"instance_type"` - Volume ComputeVolume `json:"volume"` +type WorkspaceComputeDisplay struct { + Mode string `json:"mode,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Protocol string `json:"protocol,omitempty"` } -// ValidateComputeConfig performs create-time validation on compute overrides. -// Returns nil when cfg is nil (omitted = platform-managed default). -func ValidateComputeConfig(cfg *ComputeConfig) error { - if cfg == nil { - return nil - } - if cfg.InstanceType != "" { - if len(cfg.InstanceType) > 64 { - return fmt.Errorf("compute.instance_type too long (max 64 chars)") - } - } - if cfg.Volume.RootGB != 0 { - if cfg.Volume.RootGB < 32 { - return fmt.Errorf("compute.volume.root_gb must be at least 32") - } - if cfg.Volume.RootGB > 2048 { - return fmt.Errorf("compute.volume.root_gb exceeds maximum 2048") - } - } - return nil +type WorkspaceCompute struct { + InstanceType string `json:"instance_type,omitempty"` + Volume WorkspaceComputeVolume `json:"volume,omitempty"` + Display WorkspaceComputeDisplay `json:"display,omitempty"` } type CreateWorkspacePayload struct { - Name string `json:"name" binding:"required"` - Role string `json:"role"` - Template string `json:"template"` // workspace-configs-templates folder name - Tier int `json:"tier"` - Model string `json:"model"` - Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc. - External bool `json:"external"` // true = no Docker container, just a registered URL - URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll) + Name string `json:"name" binding:"required"` + Role string `json:"role"` + Template string `json:"template"` // workspace-configs-templates folder name + Tier int `json:"tier"` + Model string `json:"model"` + Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc. + External bool `json:"external"` // true = no Docker container, just a registered URL + URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll) // DeliveryMode: "push" (default) sends inbound A2A to URL synchronously; // "poll" records inbound to activity_logs for the agent to consume via // GET /activity?since_id=. Poll mode does not require a URL. See #2339. - DeliveryMode string `json:"delivery_mode,omitempty"` + DeliveryMode string `json:"delivery_mode,omitempty"` WorkspaceDir string `json:"workspace_dir"` // host path to mount as /workspace (empty = isolated volume) WorkspaceAccess string `json:"workspace_access"` // "none" (default), "read_only", or "read_write" — see #65 ParentID *string `json:"parent_id"` @@ -219,10 +197,11 @@ type CreateWorkspacePayload struct { // MaxConcurrentTasks caps parallel A2A + cron dispatch. 0 means use // DefaultMaxConcurrentTasks. Leaders typically set 3. MaxConcurrentTasks int `json:"max_concurrent_tasks"` - // Compute is an optional per-workspace EC2 shape override. - // Omitted = platform-managed default (current behaviour). - Compute *ComputeConfig `json:"compute,omitempty"` - Canvas struct { + // Compute is the product-facing per-workspace EC2 shape/display + // contract. Phase 1 uses instance_type + volume.root_gb and persists + // display for future desktop-control workspaces. + Compute WorkspaceCompute `json:"compute,omitempty"` + Canvas struct { X float64 `json:"x"` Y float64 `json:"y"` } `json:"canvas"` diff --git a/workspace-server/internal/models/workspace_compute_test.go b/workspace-server/internal/models/workspace_compute_test.go deleted file mode 100644 index 86b4aa5d8..000000000 --- a/workspace-server/internal/models/workspace_compute_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package models - -import "testing" - -func TestValidateComputeConfig_NilIsValid(t *testing.T) { - if err := ValidateComputeConfig(nil); err != nil { - t.Errorf("nil compute config should be valid, got: %v", err) - } -} - -func TestValidateComputeConfig_EmptyIsValid(t *testing.T) { - cfg := &ComputeConfig{} - if err := ValidateComputeConfig(cfg); err != nil { - t.Errorf("empty compute config should be valid, got: %v", err) - } -} - -func TestValidateComputeConfig_ValidOverrides(t *testing.T) { - cfg := &ComputeConfig{ - InstanceType: "g4dn.xlarge", - Volume: ComputeVolume{RootGB: 256}, - } - if err := ValidateComputeConfig(cfg); err != nil { - t.Errorf("valid overrides should pass, got: %v", err) - } -} - -func TestValidateComputeConfig_InstanceTypeTooLong(t *testing.T) { - longName := string(make([]byte, 65)) - for i := range longName { - longName = longName[:i] + "x" + longName[i+1:] - } - cfg := &ComputeConfig{InstanceType: longName} - if err := ValidateComputeConfig(cfg); err == nil { - t.Error("expected error for instance_type > 64 chars") - } else if err.Error() != "compute.instance_type too long (max 64 chars)" { - t.Errorf("unexpected error message: %q", err.Error()) - } -} - -func TestValidateComputeConfig_RootGBTooSmall(t *testing.T) { - cfg := &ComputeConfig{Volume: ComputeVolume{RootGB: 31}} - if err := ValidateComputeConfig(cfg); err == nil { - t.Error("expected error for root_gb < 32") - } else if err.Error() != "compute.volume.root_gb must be at least 32" { - t.Errorf("unexpected error message: %q", err.Error()) - } -} - -func TestValidateComputeConfig_RootGBTooLarge(t *testing.T) { - cfg := &ComputeConfig{Volume: ComputeVolume{RootGB: 2049}} - if err := ValidateComputeConfig(cfg); err == nil { - t.Error("expected error for root_gb > 2048") - } else if err.Error() != "compute.volume.root_gb exceeds maximum 2048" { - t.Errorf("unexpected error message: %q", err.Error()) - } -} - -func TestValidateComputeConfig_BoundaryValues(t *testing.T) { - cases := []struct { - name string - cfg ComputeConfig - ok bool - }{ - {"min root_gb", ComputeConfig{Volume: ComputeVolume{RootGB: 32}}, true}, - {"max root_gb", ComputeConfig{Volume: ComputeVolume{RootGB: 2048}}, true}, - {"just under min", ComputeConfig{Volume: ComputeVolume{RootGB: 31}}, false}, - {"just over max", ComputeConfig{Volume: ComputeVolume{RootGB: 2049}}, false}, - {"exactly 64 char type", ComputeConfig{InstanceType: string(make([]byte, 64))}, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - // fill the 64-char case with 'x' - if tc.cfg.InstanceType != "" { - b := make([]byte, len(tc.cfg.InstanceType)) - for i := range b { - b[i] = 'x' - } - tc.cfg.InstanceType = string(b) - } - err := ValidateComputeConfig(&tc.cfg) - if tc.ok && err != nil { - t.Errorf("expected valid, got: %v", err) - } - if !tc.ok && err == nil { - t.Error("expected invalid, got nil") - } - }) - } -} diff --git a/workspace-server/internal/provisioner/cp_provisioner.go b/workspace-server/internal/provisioner/cp_provisioner.go index 2c44bcd45..269816909 100644 --- a/workspace-server/internal/provisioner/cp_provisioner.go +++ b/workspace-server/internal/provisioner/cp_provisioner.go @@ -152,21 +152,19 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) { } type cpProvisionRequest struct { - OrgID string `json:"org_id"` - WorkspaceID string `json:"workspace_id"` - Runtime string `json:"runtime"` - Tier int `json:"tier"` - PlatformURL string `json:"platform_url"` - Env map[string]string `json:"env"` + OrgID string `json:"org_id"` + WorkspaceID string `json:"workspace_id"` + Runtime string `json:"runtime"` + Tier int `json:"tier"` + InstanceType string `json:"instance_type,omitempty"` + DiskGB int32 `json:"disk_gb,omitempty"` + PlatformURL string `json:"platform_url"` + Env map[string]string `json:"env"` // ConfigFiles are template + generated config files to write into the // EC2 instance's /configs directory. OFFSEC-010: collected by // collectCPConfigFiles which rejects symlinks and non-regular files // before including them. Serialised as base64 to avoid JSON escaping. ConfigFiles map[string]string `json:"config_files,omitempty"` - // Compute overrides (nullable — omitted = platform-managed default). - // Issue #1686 Phase 1. - InstanceType *string `json:"instance_type,omitempty"` - VolumeRootGB *int `json:"volume_root_gb,omitempty"` } type cpProvisionResponse struct { @@ -214,11 +212,11 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, WorkspaceID: cfg.WorkspaceID, Runtime: cfg.Runtime, Tier: cfg.Tier, + InstanceType: cfg.InstanceType, + DiskGB: cfg.DiskGB, PlatformURL: cfg.PlatformURL, Env: env, ConfigFiles: configFiles, - InstanceType: cfg.InstanceType, - VolumeRootGB: cfg.VolumeRootGB, } body, err := json.Marshal(req) diff --git a/workspace-server/internal/provisioner/cp_provisioner_test.go b/workspace-server/internal/provisioner/cp_provisioner_test.go index 6561cc84e..44e27660a 100644 --- a/workspace-server/internal/provisioner/cp_provisioner_test.go +++ b/workspace-server/internal/provisioner/cp_provisioner_test.go @@ -191,6 +191,12 @@ func TestStart_HappyPath(t *testing.T) { if body.WorkspaceID != "ws-1" || body.Runtime != "python" { t.Errorf("body mismatch: %+v", body) } + if body.InstanceType != "m6i.xlarge" { + t.Errorf("instance_type = %q, want m6i.xlarge", body.InstanceType) + } + if body.DiskGB != 100 { + t.Errorf("disk_gb = %d, want 100", body.DiskGB) + } w.WriteHeader(http.StatusCreated) _, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`) })) @@ -205,6 +211,7 @@ func TestStart_HappyPath(t *testing.T) { id, err := p.Start(context.Background(), WorkspaceConfig{ WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant", + InstanceType: "m6i.xlarge", DiskGB: 100, }) if err != nil { t.Fatalf("Start: %v", err) @@ -362,7 +369,7 @@ func TestStart_CollectsConfigFiles(t *testing.T) { p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()} _, err := p.Start(context.Background(), WorkspaceConfig{ WorkspaceID: "ws-1", - Runtime: "python", + Runtime: "python", Tier: 1, PlatformURL: "http://tenant", TemplatePath: tmpl, @@ -424,7 +431,7 @@ func TestStart_SymlinkTemplatePathError(t *testing.T) { p := &CPProvisioner{baseURL: "http://unused", orgID: "org-1", httpClient: &http.Client{Timeout: time.Second}} _, err := p.Start(context.Background(), WorkspaceConfig{ WorkspaceID: "ws-1", - Runtime: "python", + Runtime: "python", TemplatePath: symlink, // symlink root → OFFSEC-010 guard should fire }) if err == nil { @@ -1062,75 +1069,3 @@ func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) { t.Errorf("expected symlink-related error, got: %v", err) } } - -// TestStart_ComputeOverrides — when WorkspaceConfig carries InstanceType and -// VolumeRootGB, they must be forwarded in the cpProvisionRequest body so the -// CP can pass them to EC2 RunInstances. Regression guard for #1686 Phase 1. -func TestStart_ComputeOverrides(t *testing.T) { - var gotBody cpProvisionRequest - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Errorf("decode request: %v", err) - } - w.WriteHeader(http.StatusCreated) - _, _ = io.WriteString(w, `{"instance_id":"i-compute","state":"pending"}`) - })) - defer srv.Close() - - p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()} - instanceType := "g4dn.xlarge" - volumeRootGB := 256 - _, err := p.Start(context.Background(), WorkspaceConfig{ - WorkspaceID: "ws-1", - Runtime: "python", - Tier: 2, - PlatformURL: "http://tenant", - InstanceType: &instanceType, - VolumeRootGB: &volumeRootGB, - }) - if err != nil { - t.Fatalf("Start: %v", err) - } - if gotBody.InstanceType == nil || *gotBody.InstanceType != "g4dn.xlarge" { - t.Errorf("instance_type = %v, want g4dn.xlarge", gotBody.InstanceType) - } - if gotBody.VolumeRootGB == nil || *gotBody.VolumeRootGB != 256 { - t.Errorf("volume_root_gb = %v, want 256", gotBody.VolumeRootGB) - } -} - -// TestStart_ComputeOmittedWhenNil — when WorkspaceConfig has no compute -// overrides, the JSON body must omit the keys entirely (omitempty) so CP -// applies its own defaults rather than empty/zero values. -func TestStart_ComputeOmittedWhenNil(t *testing.T) { - var raw json.RawMessage - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { - t.Errorf("decode request: %v", err) - } - w.WriteHeader(http.StatusCreated) - _, _ = io.WriteString(w, `{"instance_id":"i-default","state":"pending"}`) - })) - defer srv.Close() - - p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()} - _, err := p.Start(context.Background(), WorkspaceConfig{ - WorkspaceID: "ws-1", - Runtime: "python", - Tier: 1, - PlatformURL: "http://tenant", - }) - if err != nil { - t.Fatalf("Start: %v", err) - } - var decoded map[string]interface{} - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatalf("unmarshal raw body: %v", err) - } - if _, ok := decoded["instance_type"]; ok { - t.Errorf("instance_type should be omitted when nil") - } - if _, ok := decoded["volume_root_gb"]; ok { - t.Errorf("volume_root_gb should be omitted when nil") - } -} diff --git a/workspace-server/internal/provisioner/provisioner.go b/workspace-server/internal/provisioner/provisioner.go index c12cf5b11..0dbfee309 100644 --- a/workspace-server/internal/provisioner/provisioner.go +++ b/workspace-server/internal/provisioner/provisioner.go @@ -98,6 +98,8 @@ type WorkspaceConfig struct { WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume) Tier int Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom" + InstanceType string // Optional CP EC2 instance type override (SaaS only) + DiskGB int32 // Optional CP root volume size override in GiB (SaaS only) EnvVars map[string]string // Additional env vars (API keys, etc.) PlatformURL string AwarenessURL string @@ -105,11 +107,6 @@ type WorkspaceConfig struct { WorkspaceAccess string // #65: "none" (default), "read_only", or "read_write" ResetClaudeSession bool // #12: if true, discard the claude-sessions volume before start (fresh session dir) - // Compute overrides (nullable — omitted = platform-managed default). - // Issue #1686 Phase 1. - InstanceType *string `json:"instance_type,omitempty"` - VolumeRootGB *int `json:"volume_root_gb,omitempty"` - // Image, when non-empty, overrides the runtime→image lookup. CP // (molecule-controlplane) is the single SSOT for runtime image digest // pins via its migrations/027_runtime_image_pins table — the pin is @@ -1610,4 +1607,3 @@ func parseOCIPlatform(s string) *ocispec.Platform { } return &ocispec.Platform{OS: parts[0], Architecture: parts[1]} } - diff --git a/workspace-server/migrations/050_workspace_compute.down.sql b/workspace-server/migrations/050_workspace_compute.down.sql new file mode 100644 index 000000000..f53d248d8 --- /dev/null +++ b/workspace-server/migrations/050_workspace_compute.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspaces DROP COLUMN IF EXISTS compute; diff --git a/workspace-server/migrations/050_workspace_compute.up.sql b/workspace-server/migrations/050_workspace_compute.up.sql new file mode 100644 index 000000000..97f91afe6 --- /dev/null +++ b/workspace-server/migrations/050_workspace_compute.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS compute JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/workspace-server/migrations/20260523000000_workspace_compute.down.sql b/workspace-server/migrations/20260523000000_workspace_compute.down.sql deleted file mode 100644 index 4ffa6c4d5..000000000 --- a/workspace-server/migrations/20260523000000_workspace_compute.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE workspaces - DROP COLUMN IF EXISTS compute_instance_type; - -ALTER TABLE workspaces - DROP COLUMN IF EXISTS compute_volume_root_gb; diff --git a/workspace-server/migrations/20260523000000_workspace_compute.up.sql b/workspace-server/migrations/20260523000000_workspace_compute.up.sql deleted file mode 100644 index 28dfef749..000000000 --- a/workspace-server/migrations/20260523000000_workspace_compute.up.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Per-workspace EC2 compute configuration (#1686 Phase 1). --- Allows callers to override instance_type and root volume size --- at workspace creation time. Omitted/null values preserve the --- platform-managed default (current behaviour), so this is fully --- backwards-compatible. -ALTER TABLE workspaces - ADD COLUMN IF NOT EXISTS compute_instance_type TEXT; - -ALTER TABLE workspaces - ADD COLUMN IF NOT EXISTS compute_volume_root_gb INTEGER; -- 2.52.0