Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1362b6dd01 |
@@ -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").
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspaces DROP COLUMN IF EXISTS compute;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS compute JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user