fix(workspaces): default parent_id to the org's platform root + backfill orphans (core#2609) #2610

Merged
devops-engineer merged 1 commits from fix/2609-default-parent-platform-root into main 2026-06-11 22:22:59 +00:00
5 changed files with 151 additions and 0 deletions
@@ -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)
@@ -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
@@ -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)
@@ -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;
@@ -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;