diff --git a/.env.example b/.env.example index 8f044b7f..247b4a43 100644 --- a/.env.example +++ b/.env.example @@ -50,9 +50,6 @@ MOLECULE_ENV=development # Environment label (development/ # Container/runtime detection # MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false). Triggers A2A proxy to rewrite 127.0.0.1: agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails or to force off. -# Observability (Awareness) -# AWARENESS_URL= # If set, injected into workspace containers along with a deterministic AWARENESS_NAMESPACE derived from workspace ID. Enables the cross-session memory MCP server. - # GitHub # GITHUB_REPO=owner/repo # Target repo for agent initial_prompt clone (e.g. Molecule-AI/molecule-monorepo). Read inside workspace containers. # GITHUB_TOKEN= # Personal access token / installation token used by agents that clone private repos. Register as a global secret via POST /admin/secrets for propagation to workspace env. Token is used in-URL during clone and then scrubbed from .git/config via `git remote set-url`. diff --git a/docs/api-protocol/platform-api.md b/docs/api-protocol/platform-api.md index ffcfa810..75e9c17f 100644 --- a/docs/api-protocol/platform-api.md +++ b/docs/api-protocol/platform-api.md @@ -90,8 +90,6 @@ Poll `GET /workspaces/:id/delegations` to check results. Each entry includes `de This is the recommended way for agents to delegate work — it works for all runtimes (Claude Code, LangGraph, etc.) since it operates at the platform level. -Workspace creation also assigns an `awareness_namespace` on the workspace row. That namespace is later injected into the provisioned runtime. - ### Registry | Method | Path | Description | Auth | diff --git a/docs/api-reference.md b/docs/api-reference.md index 12e94a3c..2d2cdc4e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -103,7 +103,7 @@ Migration files live in `workspace-server/migrations/` (latest: `022_workspace_s | Table | Description | |-------|-------------| -| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `awareness_namespace`, `workspace_dir` | +| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `workspace_dir` | | `canvas_layouts` | Per-workspace x/y canvas position | | `structure_events` | Append-only event log (workspace lifecycle, agent, approval events) | | `activity_logs` | A2A communications, task updates, agent logs, errors. `error_detail` is populated by the scheduler so cron run history can surface failure reasons. | diff --git a/workspace-server/internal/handlers/handlers_additional_test.go b/workspace-server/internal/handlers/handlers_additional_test.go index 0819139c..9f8c939f 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", &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"). + WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). 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", (*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 a83a67a4..04d59693 100644 --- a/workspace-server/internal/handlers/handlers_test.go +++ b/workspace-server/internal/handlers/handlers_test.go @@ -364,11 +364,11 @@ func TestWorkspaceCreate(t *testing.T) { // Expect transaction begin for atomic workspace+secrets creation mock.ExpectBegin() - // Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime, awareness_namespace). + // Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime). // 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", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) // Expect transaction commit (no secrets in this payload) @@ -412,24 +412,17 @@ func TestWorkspaceCreate(t *testing.T) { if resp["id"] == nil || resp["id"] == "" { t.Error("expected non-empty id in response") } - if resp["awareness_namespace"] != "workspace:"+resp["id"].(string) { - t.Errorf("expected awareness namespace derived from workspace id, got %v", resp["awareness_namespace"]) - } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet sqlmock expectations: %v", err) } } -func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) { +func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) { setupTestDB(t) - // runtime_image_pins reader removed by RFC internal#617 / task #335 - // — CP is the SSOT for runtime image pins. No DB lookup here anymore. - broadcaster := newTestBroadcaster() handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs") - t.Setenv("AWARENESS_URL", "http://awareness:37800") t.Setenv("WORKSPACE_DIR", "/tmp/workspace") cfg := handler.buildProvisionerConfig( @@ -440,17 +433,10 @@ func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) { models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"}, map[string]string{"OPENAI_API_KEY": "sk-test"}, "/tmp/plugins", - "workspace:ws-123", ) - if cfg.AwarenessURL != "http://awareness:37800" { - t.Fatalf("expected awareness URL to be injected, got %q", cfg.AwarenessURL) - } - if cfg.AwarenessNamespace != "workspace:ws-123" { - t.Fatalf("expected awareness namespace to be injected, got %q", cfg.AwarenessNamespace) - } if cfg.WorkspacePath != "/tmp/workspace" { - t.Fatalf("expected workspace path from env, got %q", cfg.WorkspacePath) + t.Fatalf("expected workspace path from payload, got %q", cfg.WorkspacePath) } } diff --git a/workspace-server/internal/handlers/org.go b/workspace-server/internal/handlers/org.go index b6dedec4..5bc6bd32 100644 --- a/workspace-server/internal/handlers/org.go +++ b/workspace-server/internal/handlers/org.go @@ -799,13 +799,12 @@ func (h *OrgHandler) Import(c *gin.Context) { if len(tmpl.GlobalMemories) > 0 && len(results) > 0 { rootID, _ := results[0]["id"].(string) if rootID != "" { - rootNS := workspaceAwarenessNamespace(rootID) // Force scope to GLOBAL regardless of what the YAML says. globalSeeds := make([]models.MemorySeed, len(tmpl.GlobalMemories)) for i, gm := range tmpl.GlobalMemories { globalSeeds[i] = models.MemorySeed{Content: gm.Content, Scope: "GLOBAL"} } - seedInitialMemories(context.Background(), rootID, globalSeeds, rootNS) + seedInitialMemories(context.Background(), rootID, globalSeeds) log.Printf("Org import: seeded %d global memories on root workspace %s", len(globalSeeds), rootID) } } diff --git a/workspace-server/internal/handlers/org_import.go b/workspace-server/internal/handlers/org_import.go index cdce0639..0ab1e3b4 100644 --- a/workspace-server/internal/handlers/org_import.go +++ b/workspace-server/internal/handlers/org_import.go @@ -102,7 +102,6 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX } id := uuid.New().String() - awarenessNS := workspaceAwarenessNamespace(id) var role interface{} if ws.Role != "" { @@ -168,13 +167,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX // EXACTLY for Postgres to consider the index applicable. var insertedID string err := db.DB.QueryRowContext(ctx, ` - INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name) WHERE status != 'removed' DO NOTHING RETURNING id - `, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID) + `, id, ws.Name, role, tier, runtime, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID) if errors.Is(err, sql.ErrNoRows) { // Skip path — a non-removed row already exists for // (parent_id, name). Re-select its id; idempotency-friendly @@ -259,7 +258,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX if len(wsMemories) == 0 { wsMemories = defaults.InitialMemories } - seedInitialMemories(ctx, id, wsMemories, awarenessNS) + seedInitialMemories(ctx, id, wsMemories) // Handle external workspaces if ws.External { diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index 04513157..f92c7e3a 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -216,7 +216,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { } id := uuid.New().String() - awarenessNamespace := workspaceAwarenessNamespace(id) if h.IsSaaS() { // SaaS hard gate: every hosted workspace gets its own sibling // EC2 instance, so T4 is the only meaningful runtime boundary. @@ -448,10 +447,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // returns the actually-persisted name (which we MUST thread back into // payload + broadcast so the canvas displays what the DB has). 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, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode) + VALUES ($1, $2, $3, $4, $5, 'provisioning', $6, $7, $8, $9, $10, $11) ` - 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, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode} persistedName, currentTx, err := insertWorkspaceWithNameRetry( ctx, tx, @@ -572,7 +571,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // Seed initial memories from the create payload (issue #1050). // Non-fatal: failures are logged but don't block workspace creation. - seedInitialMemories(ctx, id, payload.InitialMemories, awarenessNamespace) + seedInitialMemories(ctx, id, payload.InitialMemories) // Broadcast provisioning event. Include `runtime` so the canvas can // populate the Runtime pill on the side panel immediately — without it @@ -707,10 +706,9 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { } c.JSON(http.StatusCreated, gin.H{ - "id": id, - "status": "provisioning", - "awareness_namespace": awarenessNamespace, - "workspace_access": workspaceAccess, + "id": id, + "status": "provisioning", + "workspace_access": workspaceAccess, }) } diff --git a/workspace-server/internal/handlers/workspace_budget_test.go b/workspace-server/internal/handlers/workspace_budget_test.go index 2a5f0bc0..3ffb2560 100644 --- a/workspace-server/internal/handlers/workspace_budget_test.go +++ b/workspace-server/internal/handlers/workspace_budget_test.go @@ -152,7 +152,6 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) { 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 diff --git a/workspace-server/internal/handlers/workspace_compute_test.go b/workspace-server/internal/handlers/workspace_compute_test.go index fbd9fec5..f9d0ba49 100644 --- a/workspace-server/internal/handlers/workspace_compute_test.go +++ b/workspace-server/internal/handlers/workspace_compute_test.go @@ -162,7 +162,6 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) { }, nil, t.TempDir(), - "workspace:ws-compute", ) if cfg.InstanceType != "m6i.xlarge" { diff --git a/workspace-server/internal/handlers/workspace_create_name_integration_test.go b/workspace-server/internal/handlers/workspace_create_name_integration_test.go index 7866a359..f0cc8b28 100644 --- a/workspace-server/internal/handlers/workspace_create_name_integration_test.go +++ b/workspace-server/internal/handlers/workspace_create_name_integration_test.go @@ -103,13 +103,13 @@ func cleanupTestRows(t *testing.T, conn *sql.DB, namePrefix string) { // TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision // exercises the helper end-to-end against a real Postgres: // -// 1. INSERT a row with name "-Repro" — succeeds. -// 2. Run insertWorkspaceWithNameRetry with the same name — -// partial-unique violation fires, helper retries with -// " (2)", that succeeds. -// 3. SELECT the row by id, confirm name = "-Repro (2)". -// 4. Run helper AGAIN — second collision, helper retries with -// " (3)". +// 1. INSERT a row with name "-Repro" — succeeds. +// 2. Run insertWorkspaceWithNameRetry with the same name — +// partial-unique violation fires, helper retries with +// " (2)", that succeeds. +// 3. SELECT the row by id, confirm name = "-Repro (2)". +// 4. Run helper AGAIN — second collision, helper retries with +// " (3)". // // This is the live-test that proves the partial-index behaviour // matches the migration's intent — sqlmock cannot reach this depth. @@ -130,9 +130,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin // targets + the NOT NULL columns required by the schema). firstID := uuid.New().String() if _, err := conn.ExecContext(ctx, ` - INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status) - VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning') - `, firstID, baseName, "workspace:"+firstID); err != nil { + INSERT INTO workspaces (id, name, tier, runtime, status) + VALUES ($1, $2, 2, 'claude-code', 'provisioning') + `, firstID, baseName); err != nil { t.Fatalf("seed first row: %v", err) } @@ -145,10 +145,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin } secondID := uuid.New().String() query := ` - INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status) - VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning') + INSERT INTO workspaces (id, name, tier, runtime, status) + VALUES ($1, $2, 2, 'claude-code', 'provisioning') ` - args := []any{secondID, baseName, "workspace:" + secondID} + args := []any{secondID, baseName} persistedName, finalTx, err := insertWorkspaceWithNameRetry( ctx, tx, beginTx, baseName, 1, query, args, ) @@ -179,7 +179,7 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin t.Fatalf("begin tx3: %v", err) } thirdID := uuid.New().String() - args3 := []any{thirdID, baseName, "workspace:" + thirdID} + args3 := []any{thirdID, baseName} persistedName3, finalTx3, err := insertWorkspaceWithNameRetry( ctx, tx3, beginTx, baseName, 1, query, args3, ) @@ -216,9 +216,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te // Seed a row, then tombstone it. firstID := uuid.New().String() if _, err := conn.ExecContext(ctx, ` - INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status) - VALUES ($1, $2, 2, 'claude-code', $3, 'removed') - `, firstID, baseName, "workspace:"+firstID); err != nil { + INSERT INTO workspaces (id, name, tier, runtime, status) + VALUES ($1, $2, 2, 'claude-code', 'removed') + `, firstID, baseName); err != nil { t.Fatalf("seed tombstoned row: %v", err) } @@ -231,10 +231,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te } secondID := uuid.New().String() query := ` - INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status) - VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning') + INSERT INTO workspaces (id, name, tier, runtime, status) + VALUES ($1, $2, 2, 'claude-code', 'provisioning') ` - args := []any{secondID, baseName, "workspace:" + secondID} + args := []any{secondID, baseName} persistedName, finalTx, err := insertWorkspaceWithNameRetry( ctx, tx, beginTx, baseName, 1, query, args, ) diff --git a/workspace-server/internal/handlers/workspace_provision.go b/workspace-server/internal/handlers/workspace_provision.go index 9381afb3..f6316773 100644 --- a/workspace-server/internal/handlers/workspace_provision.go +++ b/workspace-server/internal/handlers/workspace_provision.go @@ -128,7 +128,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri workspaceID, filepath.Base(runtimeTemplate)) templatePath = runtimeTemplate // Rebuild cfg with the recovered template path so Start() sees it. - cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath, prepared.AwarenessNamespace) + cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath) cfg.ResetClaudeSession = resetClaudeSession recovered = true break @@ -194,10 +194,11 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri // a ~64k context window worth of text — but small enough to prevent abuse. const maxMemoryContentLength = 100_000 // ~100 KiB of text -func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed, awarenessNamespace string) { +func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed) { if len(memories) == 0 { return } + namespace := workspaceMemoryNamespace(workspaceID) for _, mem := range memories { scope := strings.ToUpper(mem.Scope) if scope == "" { @@ -223,33 +224,27 @@ func seedInitialMemories(ctx context.Context, workspaceID string, memories []mod if _, err := db.DB.ExecContext(ctx, ` INSERT INTO agent_memories (workspace_id, content, scope, namespace) VALUES ($1, $2, $3, $4) - `, workspaceID, redactedContent, scope, awarenessNamespace); err != nil { + `, workspaceID, redactedContent, scope, namespace); err != nil { log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err) } } log.Printf("seedInitialMemories: seeded %d memories for workspace %s", len(memories), workspaceID) } -func workspaceAwarenessNamespace(workspaceID string) string { +// workspaceMemoryNamespace returns the canonical v2 memory namespace +// string for a workspace. Matches the form produced by +// internal/memory/namespace/resolver.go for self-reads (issue #1735). +func workspaceMemoryNamespace(workspaceID string) string { return fmt.Sprintf("workspace:%s", workspaceID) } -func (h *WorkspaceHandler) loadAwarenessNamespace(ctx context.Context, workspaceID string) string { - var awarenessNamespace string - err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(awareness_namespace, '') FROM workspaces WHERE id = $1`, workspaceID).Scan(&awarenessNamespace) - if err != nil || awarenessNamespace == "" { - return workspaceAwarenessNamespace(workspaceID) - } - return awarenessNamespace -} - func (h *WorkspaceHandler) buildProvisionerConfig( ctx context.Context, workspaceID, templatePath string, configFiles map[string][]byte, payload models.CreateWorkspacePayload, envVars map[string]string, - pluginsPath, awarenessNamespace string, + pluginsPath string, ) provisioner.WorkspaceConfig { // Per-workspace workspace_dir takes priority over global WORKSPACE_DIR env var. // If neither is set, the provisioner creates an isolated Docker volume. @@ -304,10 +299,8 @@ func (h *WorkspaceHandler) buildProvisionerConfig( Height: payload.Compute.Display.Height, Protocol: payload.Compute.Display.Protocol, }, - EnvVars: envVars, - PlatformURL: h.platformURL, - AwarenessURL: os.Getenv("AWARENESS_URL"), - AwarenessNamespace: awarenessNamespace, + EnvVars: envVars, + PlatformURL: h.platformURL, // Image left empty — molecule-core's runtime_image_pins table (mig // 047, dead reader removed by RFC internal#617 / task #335) was an // aspirational SSOT that never received a writer. CP's diff --git a/workspace-server/internal/handlers/workspace_provision_shared.go b/workspace-server/internal/handlers/workspace_provision_shared.go index 80677623..d2b42101 100644 --- a/workspace-server/internal/handlers/workspace_provision_shared.go +++ b/workspace-server/internal/handlers/workspace_provision_shared.go @@ -85,10 +85,9 @@ func readOrLazyHealInboundSecret(ctx context.Context, workspaceID, opLabel strin // prepareProvisionContext when the caller proceeds; nil + non-empty // abort message when the caller must mark the workspace failed. type preparedProvisionContext struct { - EnvVars map[string]string - PluginsPath string - AwarenessNamespace string - Config provisioner.WorkspaceConfig + EnvVars map[string]string + PluginsPath string + Config provisioner.WorkspaceConfig } // provisionAbort describes why prepareProvisionContext refused to @@ -170,7 +169,6 @@ func (h *WorkspaceHandler) prepareProvisionContext( } pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins")) - awarenessNamespace := h.loadAwarenessNamespace(ctx, workspaceID) // Per-agent git identity (#1957) — must run after secret loads so // a workspace_secret named GIT_AUTHOR_NAME can override. @@ -231,14 +229,13 @@ func (h *WorkspaceHandler) prepareProvisionContext( } } - cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath, awarenessNamespace) + cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath) cfg.ResetClaudeSession = resetClaudeSession return &preparedProvisionContext{ - EnvVars: envVars, - PluginsPath: pluginsPath, - AwarenessNamespace: awarenessNamespace, - Config: cfg, + EnvVars: envVars, + PluginsPath: pluginsPath, + Config: cfg, }, nil } diff --git a/workspace-server/internal/handlers/workspace_provision_test.go b/workspace-server/internal/handlers/workspace_provision_test.go index bad5ee87..a4275a34 100644 --- a/workspace-server/internal/handlers/workspace_provision_test.go +++ b/workspace-server/internal/handlers/workspace_provision_test.go @@ -17,9 +17,9 @@ import ( "gopkg.in/yaml.v3" ) -// ==================== workspaceAwarenessNamespace ==================== +// ==================== workspaceMemoryNamespace ==================== -func TestWorkspaceAwarenessNamespace(t *testing.T) { +func TestWorkspaceMemoryNamespace(t *testing.T) { tests := []struct { workspaceID string expected string @@ -31,9 +31,9 @@ func TestWorkspaceAwarenessNamespace(t *testing.T) { for _, tt := range tests { t.Run(tt.workspaceID, func(t *testing.T) { - result := workspaceAwarenessNamespace(tt.workspaceID) + result := workspaceMemoryNamespace(tt.workspaceID) if result != tt.expected { - t.Errorf("workspaceAwarenessNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected) + t.Errorf("workspaceMemoryNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected) } }) } @@ -645,7 +645,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) { WillReturnResult(sqlmock.NewResult(1, 1)) } - seedInitialMemories(context.Background(), workspaceID, memories, "test-ns") + seedInitialMemories(context.Background(), workspaceID, memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet DB expectations: %v", err) @@ -674,7 +674,7 @@ func TestSeedInitialMemories_RedactsSecrets(t *testing.T) { WithArgs(workspaceID, wantRedacted, "LOCAL", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) - seedInitialMemories(context.Background(), workspaceID, memories, "test-ns") + seedInitialMemories(context.Background(), workspaceID, memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet DB expectations: %v", err) @@ -691,7 +691,7 @@ func TestSeedInitialMemories_InvalidScopeSkipped(t *testing.T) { {Content: "this should be skipped", Scope: "NOT_A_REAL_SCOPE"}, } - seedInitialMemories(context.Background(), "ws-bad-scope", memories, "test-ns") + seedInitialMemories(context.Background(), "ws-bad-scope", memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unexpected DB calls for invalid scope: %v", err) @@ -704,7 +704,7 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) { mock := setupTestDB(t) mock.ExpectationsWereMet() - seedInitialMemories(context.Background(), "ws-nil", nil, "test-ns") + seedInitialMemories(context.Background(), "ws-nil", nil) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unexpected DB calls for nil slice: %v", err) @@ -733,7 +733,6 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) { models.CreateWorkspacePayload{Tier: 1, Runtime: "langgraph"}, map[string]string{"API_KEY": "secret"}, pluginsPath, - "workspace:ws-basic", ) if cfg.WorkspaceID != "ws-basic" { @@ -748,9 +747,6 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) { if cfg.PlatformURL != "http://localhost:8080" { t.Errorf("expected PlatformURL 'http://localhost:8080', got %q", cfg.PlatformURL) } - if cfg.AwarenessNamespace != "workspace:ws-basic" { - t.Errorf("expected AwarenessNamespace 'workspace:ws-basic', got %q", cfg.AwarenessNamespace) - } if cfg.PluginsPath != pluginsPath { t.Errorf("expected PluginsPath %q, got %q", pluginsPath, cfg.PluginsPath) } @@ -775,7 +771,6 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) { workspaceDir := t.TempDir() t.Setenv("WORKSPACE_DIR", workspaceDir) - t.Setenv("AWARENESS_URL", "http://awareness:37800") pluginsPath := t.TempDir() cfg := handler.buildProvisionerConfig( @@ -786,15 +781,11 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) { models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"}, nil, pluginsPath, - "workspace:ws-env", ) if cfg.WorkspacePath != workspaceDir { t.Errorf("expected WorkspacePath from env, got %q", cfg.WorkspacePath) } - if cfg.AwarenessURL != "http://awareness:37800" { - t.Errorf("expected AwarenessURL from env, got %q", cfg.AwarenessURL) - } } // ==================== issueAndInjectToken (issue #418) ==================== @@ -1007,7 +998,7 @@ func TestSeedInitialMemories_Truncation(t *testing.T) { WithArgs(sqlmock.AnyArg(), expectTruncated, "LOCAL", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) - seedInitialMemories(context.Background(), "ws-1066-test", memories, "test-ns") + seedInitialMemories(context.Background(), "ws-1066-test", memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("DB expectations not met: %v\n"+ @@ -1027,7 +1018,7 @@ func TestSeedInitialMemories_ContentUnderLimit(t *testing.T) { WithArgs(sqlmock.AnyArg(), "short content", "TEAM", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) - seedInitialMemories(context.Background(), "ws-1066-under", memories, "test-ns") + seedInitialMemories(context.Background(), "ws-1066-under", memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("DB expectations not met: %v", err) @@ -1052,7 +1043,7 @@ func TestSeedInitialMemories_ExactlyAtLimit(t *testing.T) { WithArgs(sqlmock.AnyArg(), atLimitContent, "LOCAL", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) - seedInitialMemories(context.Background(), "ws-boundary", memories, "test-ns") + seedInitialMemories(context.Background(), "ws-boundary", memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("DB expectations not met: %v", err) @@ -1068,7 +1059,7 @@ func TestSeedInitialMemories_EmptyContent(t *testing.T) { } // seedInitialMemories skips empty content at line 234 — no DB call expected. - seedInitialMemories(context.Background(), "ws-empty", memories, "test-ns") + seedInitialMemories(context.Background(), "ws-empty", memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("DB expectations not met: %v", err) @@ -1092,7 +1083,7 @@ func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) { WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "GLOBAL", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(0, 1)) - seedInitialMemories(context.Background(), "ws-secrets", memories, "test-ns") + seedInitialMemories(context.Background(), "ws-secrets", memories) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("DB expectations not met: %v", err) diff --git a/workspace-server/internal/handlers/workspace_test.go b/workspace-server/internal/handlers/workspace_test.go index d0b8bc19..2c8cf577 100644 --- a/workspace-server/internal/handlers/workspace_test.go +++ b/workspace-server/internal/handlers/workspace_test.go @@ -342,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"). + WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnError(sql.ErrConnDone) mock.ExpectRollback() @@ -375,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"). + WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() @@ -423,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"). + WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -464,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"). + WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", (*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"). @@ -576,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"). + WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", (*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). @@ -615,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"). + WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", (*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" @@ -1639,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), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1696,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), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1749,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), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() mock.ExpectExec("INSERT INTO canvas_layouts"). @@ -1894,7 +1894,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", (*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 ecdc7a02..b6fd1046 100644 --- a/workspace-server/internal/models/workspace.go +++ b/workspace-server/internal/models/workspace.go @@ -17,7 +17,6 @@ type Workspace struct { Name string `json:"name" db:"name"` Role sql.NullString `json:"role" db:"role"` Tier int `json:"tier" db:"tier"` - AwarenessNamespace sql.NullString `json:"awareness_namespace" db:"awareness_namespace"` Status string `json:"status" db:"status"` SourceBundleID sql.NullString `json:"source_bundle_id" db:"source_bundle_id"` AgentCard json.RawMessage `json:"agent_card" db:"agent_card"` @@ -207,7 +206,8 @@ type CreateWorkspacePayload struct { } `json:"canvas"` // InitialMemories is an optional list of memories to seed into the // workspace immediately after creation. Each entry is inserted into - // agent_memories with the workspace's awareness namespace. Issue #1050. + // agent_memories under the workspace's v2 memory namespace + // ("workspace:"). Issue #1050. InitialMemories []MemorySeed `json:"initial_memories"` } diff --git a/workspace-server/internal/provisioner/provisioner.go b/workspace-server/internal/provisioner/provisioner.go index 4145f91e..c4318906 100644 --- a/workspace-server/internal/provisioner/provisioner.go +++ b/workspace-server/internal/provisioner/provisioner.go @@ -103,8 +103,6 @@ type WorkspaceConfig struct { Display WorkspaceDisplayConfig EnvVars map[string]string // Additional env vars (API keys, etc.) PlatformURL string - AwarenessURL string - AwarenessNamespace string 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) @@ -714,10 +712,6 @@ func buildContainerEnv(cfg WorkspaceConfig) []string { // still override (Dockerfile ENV is overridden by docker -e at runtime). "PYTHONPATH=/app", } - if cfg.AwarenessNamespace != "" && cfg.AwarenessURL != "" { - env = append(env, fmt.Sprintf("AWARENESS_NAMESPACE=%s", cfg.AwarenessNamespace)) - env = append(env, fmt.Sprintf("AWARENESS_URL=%s", cfg.AwarenessURL)) - } // #1687: track explicit GH_TOKEN / GITHUB_TOKEN so they win over GH_PAT // alias. These are normally stripped by the SCM-write guard below, but // when a user explicitly sets them we preserve the value. diff --git a/workspace-server/internal/provisioner/provisioner_test.go b/workspace-server/internal/provisioner/provisioner_test.go index 7302d333..d258f4e3 100644 --- a/workspace-server/internal/provisioner/provisioner_test.go +++ b/workspace-server/internal/provisioner/provisioner_test.go @@ -692,39 +692,6 @@ func TestBuildContainerEnv_MoleculeAIURLAlwaysMatchesPlatformURL(t *testing.T) { } } -func TestBuildContainerEnv_AwarenessOnlyWhenBothSet(t *testing.T) { - // Both set → both injected. - cfg := WorkspaceConfig{ - WorkspaceID: "ws-x", - PlatformURL: "http://localhost:8080", - AwarenessURL: "http://awareness:9000", - AwarenessNamespace: "ns-1", - } - env := buildContainerEnv(cfg) - hasNS := false - hasURL := false - for _, e := range env { - if e == "AWARENESS_NAMESPACE=ns-1" { - hasNS = true - } - if e == "AWARENESS_URL=http://awareness:9000" { - hasURL = true - } - } - if !hasNS || !hasURL { - t.Errorf("both awareness vars must be present: env=%v", env) - } - - // Only namespace set → neither injected (must be both-or-nothing). - cfg.AwarenessURL = "" - env2 := buildContainerEnv(cfg) - for _, e := range env2 { - if strings.HasPrefix(e, "AWARENESS_") { - t.Errorf("awareness vars must NOT be injected when URL is missing: got %q", e) - } - } -} - func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) { // NOTE: this test previously asserted GITHUB_TOKEN passed through // verbatim. That assertion encoded the forensic #145 latent leak as diff --git a/workspace-server/migrations/20260523130000_drop_workspaces_awareness_namespace.down.sql b/workspace-server/migrations/20260523130000_drop_workspaces_awareness_namespace.down.sql new file mode 100644 index 00000000..97dec577 --- /dev/null +++ b/workspace-server/migrations/20260523130000_drop_workspaces_awareness_namespace.down.sql @@ -0,0 +1,11 @@ +-- Reverse of 20260523130000_drop_workspaces_awareness_namespace.up.sql. +-- +-- Restores the workspaces.awareness_namespace column verbatim from +-- migration 010_workspace_awareness.sql so a down-cycle leaves the +-- schema bit-identical to the pre-drop state. The column will be +-- NULL on all rows after re-add — handlers no longer write to it and +-- callers no longer read it, so this is functionally inert without +-- a paired code revert. + +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS awareness_namespace TEXT; diff --git a/workspace-server/migrations/20260523130000_drop_workspaces_awareness_namespace.up.sql b/workspace-server/migrations/20260523130000_drop_workspaces_awareness_namespace.up.sql new file mode 100644 index 00000000..84a069d4 --- /dev/null +++ b/workspace-server/migrations/20260523130000_drop_workspaces_awareness_namespace.up.sql @@ -0,0 +1,19 @@ +-- Issue #1735 — drop the workspaces.awareness_namespace column. +-- +-- "Awareness namespaces" were a memory-routing surface (env vars +-- AWARENESS_URL / AWARENESS_NAMESPACE) that was plumbed across the +-- platform but never wired in any production or staging environment +-- (verified 2026-05-23 via Railway GraphQL on the controlplane service: +-- AWARENESS_* unset in both env IDs 59227671-… and 639539ec-…). +-- +-- The column added by migration 010_workspace_awareness.sql was only +-- ever populated with the canonical "workspace:" string, which is +-- also the v2 memory namespace string (see internal/memory/namespace/ +-- resolver.go:186). Removing the column does not change any agent- +-- visible memory namespace — handlers now compute the same +-- "workspace:" string inline when inserting into agent_memories. +-- +-- Related: #1733 (memory SSOT consolidation), #1734 (Memory tab bug). + +ALTER TABLE workspaces + DROP COLUMN IF EXISTS awareness_namespace;