fix(workspaces): default parent_id to the org's platform root + backfill orphans (core#2609) #2610
@@ -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)
|
||||
|
||||
+3
@@ -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;
|
||||
+29
@@ -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;
|
||||
Reference in New Issue
Block a user