diff --git a/workspace-server/internal/handlers/handlers_additional_test.go b/workspace-server/internal/handlers/handlers_additional_test.go index f06bbc15..16d76304 100644 --- a/workspace-server/internal/handlers/handlers_additional_test.go +++ b/workspace-server/internal/handlers/handlers_additional_test.go @@ -64,6 +64,83 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) { // ---------- workspace.go: Create with explicit runtime ---------- +// TestWorkspaceCreate_DefaultsParentToPlatformRoot (core#2609): a create +// without an explicit parent_id must land under the org's single platform +// root — NOT as a parent_id-NULL orphan root, which A2A then walls off +// ("cannot communicate per hierarchy rules") and the canvas renders depth-1 +// beside the root (#2601). +func TestWorkspaceCreate_DefaultsParentToPlatformRoot(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs") + + rootID := "11111111-2222-3333-4444-555555555555" + mock.ExpectQuery(`SELECT id FROM workspaces WHERE COALESCE\(kind, 'workspace'\) = 'platform'`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(rootID)) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO workspaces"). + WithArgs(sqlmock.AnyArg(), "Team Member", nil, 3, "claude-code", rootID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectExec("INSERT INTO workspace_secrets").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO structure_events").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO workspace_auth_tokens").WillReturnResult(sqlmock.NewResult(0, 1)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `{"name":"Team Member","model":"anthropic:claude-opus-4-7"}` + 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 201, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestWorkspaceCreate_NoPlatformRoot_KeepsNullParent (core#2609): with no +// platform root in the DB (bootstrap / pre-install), the create keeps the +// legacy NULL parent — defaulting is best-effort, never a new failure mode. +func TestWorkspaceCreate_NoPlatformRoot_KeepsNullParent(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs") + + mock.ExpectQuery(`SELECT id FROM workspaces WHERE COALESCE\(kind, 'workspace'\) = 'platform'`). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO workspaces"). + WithArgs(sqlmock.AnyArg(), "Bootstrap Root", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectExec("INSERT INTO workspace_secrets").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO structure_events").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO workspace_auth_tokens").WillReturnResult(sqlmock.NewResult(0, 1)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `{"name":"Bootstrap Root","model":"anthropic:claude-opus-4-7"}` + 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 201, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) diff --git a/workspace-server/internal/handlers/platform_agent.go b/workspace-server/internal/handlers/platform_agent.go index 22cd048a..461c1852 100644 --- a/workspace-server/internal/handlers/platform_agent.go +++ b/workspace-server/internal/handlers/platform_agent.go @@ -163,6 +163,36 @@ const conciergeMCPFragmentFile = "mcp_servers.yaml" // sufficient and stable across restarts. var SelfHostedPlatformAgentID = uuid.NewSHA1(uuid.NameSpaceURL, []byte("molecule:self-hosted:platform-agent")).String() +// platformRootWorkspaceID returns the org's single kind='platform' root +// workspace id, or "" when there is none (pre-install / bootstrap) or more +// than one (ambiguous — multi-org self-host DB; fail soft, change nothing). +// Used by the workspace-create path to default parent_id (core#2609): a +// workspace created without an explicit parent must land UNDER the org root, +// not beside it — a parent_id-NULL orphan is outside the org subtree, so the +// concierge cannot reach it over A2A ("cannot communicate per hierarchy +// rules") and the canvas renders it depth-1 next to the root (#2601). +func platformRootWorkspaceID(ctx context.Context) string { + rows, err := db.DB.QueryContext(ctx, + `SELECT id FROM workspaces WHERE COALESCE(kind, 'workspace') = 'platform' AND status != 'removed' LIMIT 2`) + if err != nil { + // Fail soft: defaulting the parent is best-effort; create must not + // fail because the root lookup hiccuped. + return "" + } + defer rows.Close() + ids := make([]string, 0, 2) + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + if len(ids) == 1 { + return ids[0] + } + return "" +} + // defaultPlatformAgentName returns the display name for the org's platform // agent (the concierge). When the tenant server is told its org's name via the // MOLECULE_ORG_NAME env (the self-hosted docker-compose sets it; SaaS passes an diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index 6e6c0d8c..93148862 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -561,6 +561,18 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { } } + // core#2609: no explicit parent -> default under the org's platform root. + // Live failure this prevents: the enter-os concierge provisioned its team + // via the management surface, both workspaces landed parent_id NULL, and + // every delegation died with "cannot communicate per hierarchy rules". + // Best-effort: no platform root (or an ambiguous >1) leaves NULL intact, + // preserving bootstrap/self-host multi-root behavior. + if payload.ParentID == nil { + if rootID := platformRootWorkspaceID(ctx); rootID != "" { + payload.ParentID = &rootID + } + } + tx, txErr := db.DB.BeginTx(ctx, nil) if txErr != nil { log.Printf("Create workspace: begin tx error: %v", txErr) diff --git a/workspace-server/migrations/20260611230000_workspaces_backfill_orphans_under_platform_root.down.sql b/workspace-server/migrations/20260611230000_workspaces_backfill_orphans_under_platform_root.down.sql new file mode 100644 index 00000000..f80e978a --- /dev/null +++ b/workspace-server/migrations/20260611230000_workspaces_backfill_orphans_under_platform_root.down.sql @@ -0,0 +1,3 @@ +-- Irreversible data repair: the pre-backfill NULL parents are not recorded, +-- so down is a no-op by design (re-orphaning workspaces would re-break A2A). +SELECT 1; diff --git a/workspace-server/migrations/20260611230000_workspaces_backfill_orphans_under_platform_root.up.sql b/workspace-server/migrations/20260611230000_workspaces_backfill_orphans_under_platform_root.up.sql new file mode 100644 index 00000000..fff68b10 --- /dev/null +++ b/workspace-server/migrations/20260611230000_workspaces_backfill_orphans_under_platform_root.up.sql @@ -0,0 +1,29 @@ +-- core#2609: backfill parent_id-NULL orphan workspaces under the org's +-- platform root. +-- +-- The workspace-create path could (pre-fix) insert rows with parent_id NULL +-- when no explicit parent was given — e.g. every workspace the org concierge +-- provisioned through the management surface. A NULL-parent row is an orphan +-- ROOT outside the org subtree: A2A denies it ("workspaces cannot communicate +-- per hierarchy rules") and the canvas renders it depth-1 beside the root +-- (#2601). The create path now defaults parent_id to the platform root; this +-- backfill repairs rows created before the fix. +-- +-- Guards (same semantics as the install path, which re-parents NULL roots +-- under the concierge): +-- * only runs when the DB has EXACTLY ONE live kind='platform' root — +-- a multi-root self-host DB is ambiguous and is left untouched; +-- * never touches the platform root itself or removed rows. +-- Idempotent: after the first run no qualifying NULL-parent rows remain. +UPDATE workspaces w +SET parent_id = r.id, updated_at = now() +FROM ( + SELECT id FROM workspaces + WHERE COALESCE(kind, 'workspace') = 'platform' AND status != 'removed' +) r +WHERE w.parent_id IS NULL + AND w.id <> r.id + AND COALESCE(w.kind, 'workspace') <> 'platform' + AND w.status != 'removed' + AND (SELECT count(*) FROM workspaces + WHERE COALESCE(kind, 'workspace') = 'platform' AND status != 'removed') = 1;