diff --git a/docs/agent-runtime/team-expansion.md b/docs/agent-runtime/team-expansion.md deleted file mode 100644 index 5785dd13..00000000 --- a/docs/agent-runtime/team-expansion.md +++ /dev/null @@ -1,111 +0,0 @@ -# Team Expansion (Recursive Workspaces) - -When a workspace is expanded into a team, it gains sub-workspaces while its own agent remains as the **team lead** (coordinator). This is recursive — sub-workspaces can themselves be expanded into teams, infinitely deep. - -## How It Works - -When Developer PM is expanded into a team: - -``` -Business Core - | - +-- Developer PM (agent stays, becomes coordinator) - | - +-- Frontend Agent (sub-workspace, private scope) - +-- Backend Agent (sub-workspace, private scope) - +-- QA Agent (sub-workspace, private scope) -``` - -- Developer PM's agent **still exists** and acts as coordinator -- Developer PM receives incoming A2A messages from Business Core -- Developer PM's agent decides how to delegate to sub-workspaces -- Sub-workspaces talk to Developer PM and to each other (same level) -- Sub-workspaces **cannot** talk to Business Core or any workspace outside the team - -## Communication Rules - -| Direction | Allowed? | Example | -|-----------|----------|---------| -| Parent level -> team lead | Yes | Business Core -> Developer PM | -| Team lead -> sub-workspaces | Yes | Developer PM -> Frontend Agent | -| Sub-workspace -> team lead | Yes | Frontend Agent -> Developer PM | -| Sub-workspace <-> sibling | Yes | Frontend Agent <-> Backend Agent | -| Outside -> sub-workspace directly | No (403) | Business Core -> Frontend Agent | -| Sub-workspace -> outside directly | No | Frontend Agent -> Business Core | - -The team lead (Developer PM) is the **only** bridge between the team's internal world and the outside. - -## Scoped Registry - -Sub-workspaces register in the platform registry but with a **private scope**. The registry knows about them but enforces access control. - -``` -Registry: - Business Core :8001 scope: public - Developer PM :8002 scope: public - Frontend Agent :8010 scope: private, parent=Developer PM - Backend Agent :8011 scope: private, parent=Developer PM - QA Agent :8012 scope: private, parent=Developer PM -``` - -- The platform can always discover any workspace (for provisioning, monitoring) -- The parent workspace can discover its sub-workspaces -- Sub-workspaces can discover their siblings (same parent) -- Outside workspaces get a **403 Forbidden** if they try to discover a private sub-workspace - -## How to Expand - -Expansion is triggered via `POST /workspaces/:id/expand`. The platform reads the `sub_workspaces` list from the workspace's config and provisions each one. On the canvas, users right-click a workspace node and select "Expand into team." - -Collapsing is the inverse: `POST /workspaces/:id/collapse`. Sub-workspaces are stopped and removed. - -## What Happens on Expansion - -When Developer PM is expanded into a team, the hierarchy changes but the outside view doesn't. Business Core's parent/child relationship to Developer PM is unaffected — Developer PM still responds to the same A2A endpoint. - -The events fired: -- `WORKSPACE_EXPANDED` with the new `sub_workspace_ids` in the payload -- `WORKSPACE_PROVISIONING` for each new sub-workspace -- `WORKSPACE_ONLINE` for each sub-workspace as they come up - -Communication rules are automatically derived from the new hierarchy — no manual wiring needed. - -## Canvas Behavior - -- Children render as embedded mini-cards (`TeamMemberChip`) inside the parent node, not as separate canvas nodes -- Each mini-card shows full status: gradient bar, name, tier badge, skills pills, active tasks, descendant count -- **Recursive rendering** up to 3 levels deep (`MAX_NESTING_DEPTH = 3`) — sub-cards can contain their own "Team" sections -- Parent node dynamically resizes: 210-280px (no children), 320-450px (children), 400-560px (grandchildren) -- Eject button (sky-blue arrow icon) on hover extracts a child from the team -- "Extract from Team" also available in the right-click context menu -- Double-click a team node to zoom/fit to the parent area -- The parent workspace node shows a badge with total descendant count - -## Collapsing a Team - -The inverse of expansion, triggered via `POST /workspaces/:id/collapse`: - -1. Each sub-workspace agent wraps up current work and writes a handoff document to memory -2. Sub-workspaces are stopped and removed -3. The team lead's agent goes back to handling everything directly -4. A `WORKSPACE_COLLAPSED` event fires - -Sub-workspace memory is cleaned up based on backend (see [Memory — Cleanup](../architecture/memory.md#cleanup-on-workspace-deletion)). - -## Deleting a Team Workspace - -When a team workspace is deleted: -1. Platform shows a warning listing all sub-workspaces that will be deleted -2. User can **drag sub-workspaces out** of the team before confirming (promotes them to the parent level) -3. On confirmation, cascade delete removes the parent and all remaining sub-workspaces -4. `WORKSPACE_REMOVED` events fire for each deleted workspace - -## Related Docs - -- [Communication Rules](../api-protocol/communication-rules.md) — Full access control model -- [Core Concepts](../product/core-concepts.md) — Workspace fundamentals -- [System Prompt Structure](./system-prompt-structure.md) — How peer capabilities are injected -- [Provisioner](../architecture/provisioner.md) — How sub-workspaces are deployed -- [Registry & Heartbeat](../api-protocol/registry-and-heartbeat.md) — How registration works -- [Event Log](../architecture/event-log.md) — Events fired during expansion -- [Canvas UI](../frontend/canvas.md) — Visual behavior of teams diff --git a/docs/api-reference.md b/docs/api-reference.md index e1a75668..12e94a3c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -41,8 +41,6 @@ Full contract: `docs/runbooks/admin-auth.md`. | GET | /admin/workspaces/:id/test-token | admin_test_token.go — mint a fresh bearer token for E2E scripts; returns 404 unless `MOLECULE_ENV != production` or `MOLECULE_ENABLE_TEST_TOKENS=1` | | GET/POST/DELETE | /admin/secrets[/:key] | secrets.go — legacy aliases for /settings/secrets | | WS | /workspaces/:id/terminal | terminal.go | -| POST | /workspaces/:id/expand | team.go | -| POST | /workspaces/:id/collapse | team.go | | POST/GET | /workspaces/:id/approvals | approvals.go | | POST | /workspaces/:id/approvals/:id/decide | approvals.go | | GET | /approvals/pending | approvals.go | diff --git a/docs/architecture/molecule-technical-doc.md b/docs/architecture/molecule-technical-doc.md index 0d9c653c..cd3dc957 100644 --- a/docs/architecture/molecule-technical-doc.md +++ b/docs/architecture/molecule-technical-doc.md @@ -336,8 +336,6 @@ This same logic governs: A2A delegation, memory scope enforcement, activity visi | Method | Endpoint | Purpose | |--------|----------|---------| -| `POST` | `/workspaces/:id/expand` | Expand workspace into team (become coordinator) | -| `POST` | `/workspaces/:id/collapse` | Collapse team back to single workspace | ### Files, Terminal, Templates, Bundles (8 endpoints) diff --git a/docs/frontend/canvas.md b/docs/frontend/canvas.md index 8d59c80f..fc103bd6 100644 --- a/docs/frontend/canvas.md +++ b/docs/frontend/canvas.md @@ -186,4 +186,3 @@ So the UI now exposes more operational failure state directly instead of silentl - [Quickstart](../quickstart.md) - [Platform API](../api-protocol/platform-api.md) - [Workspace Runtime](../agent-runtime/workspace-runtime.md) -- [Team Expansion](../agent-runtime/team-expansion.md) diff --git a/docs/glossary.md b/docs/glossary.md index f0343a38..b3535ae8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -18,7 +18,7 @@ lands in the watch list with a colliding term, add a row here. | **plugin** | A directory under `plugins/` packaging one or more skills or an MCP server wrapper, installable per-workspace via `POST /workspaces/:id/plugins`. Governed by `plugin.yaml`. | **Langflow**: a visual UI node / component in a flowchart. **CrewAI**: a Python-importable callable registered as a capability. | | **agent** | A persistent containerized workspace running continuously — an identity with memory, a role, and a schedule. Not a one-shot invocation. | Most frameworks (AutoGPT, LangChain agents, OpenAI Assistants): a stateless function-call loop. No persistence between invocations unless explicitly checkpointed. | | **flow** | A task execution within a workspace — a request enters, the agent runs tools, emits a response, logs activity. No explicit graph abstraction. | **Langflow**: a directed graph of nodes you author visually. **LangGraph**: a stateful graph of callable nodes. Our "flow" is an imperative timeline, not a graph. | -| **team** | A named cluster of workspaces under a PM (org template `expand_team`). Used for role grouping in Canvas. | **CrewAI**: a "crew" is a sequence of agents that pass a task through a declared order. Our "team" is an org-chart abstraction, not an execution order. | +| **team** | A named cluster of workspaces under a PM . Used for role grouping in Canvas. | **CrewAI**: a "crew" is a sequence of agents that pass a task through a declared order. Our "team" is an org-chart abstraction, not an execution order. | | **skill** | A directory with `SKILL.md` that an agent invokes via the `Skill` tool. Skills are documentation + optional scripts that teach an agent a recipe. | **Anthropic Skills API**: nearly identical. **CrewAI tool**: closer to our plugin's MCP tool, not our skill. | | **channel** | An outbound/inbound social integration (Telegram, Slack, …) per-workspace, wired in `workspace_channels`. | Slack's "channel": the container for messages. We use "channel" for the adapter + credentials, not the conversation itself. | | **runtime** | The execution engine image tag for a workspace: one of `langgraph`, `claude-code`, `openclaw`, `crewai`, `autogen`, `deepagents`, `hermes`. | **LangGraph runtime**: the Python process running the graph. We use "runtime" for the Docker image + adapter pairing, not the inner process. | diff --git a/docs/guides/mcp-server-setup.md b/docs/guides/mcp-server-setup.md index aacc554a..5539ba97 100644 --- a/docs/guides/mcp-server-setup.md +++ b/docs/guides/mcp-server-setup.md @@ -166,8 +166,6 @@ list_workspaces | MCP Tool | API Route | Method | Description | |----------|-----------|--------|-------------| -| `expand_team` | `/workspaces/:id/expand` | POST | Expand team node | -| `collapse_team` | `/workspaces/:id/collapse` | POST | Collapse team node | ### Templates & Bundles diff --git a/workspace-server/internal/handlers/team.go b/workspace-server/internal/handlers/team.go deleted file mode 100644 index 0c536020..00000000 --- a/workspace-server/internal/handlers/team.go +++ /dev/null @@ -1,132 +0,0 @@ -package handlers - -import ( - "encoding/json" - "log" - "net/http" - "os" - "path/filepath" - - "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/events" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/models" - "github.com/gin-gonic/gin" - "gopkg.in/yaml.v3" -) - -// TeamHandler now hosts only Collapse — the visual "expand" action is -// canvas-side and creating children goes through the regular -// WorkspaceHandler.Create path with parent_id set, like any other -// workspace. Every workspace can have children; "team" is just the -// state of having children. The old Expand handler bulk-created -// children by reading sub_workspaces from a parent's config and was -// non-idempotent — calling it N times leaked N×children EC2s, which -// is how tenant-hongming accumulated 72 stale workspaces. -type TeamHandler struct { - wh *WorkspaceHandler - b *events.Broadcaster -} - -// NewTeamHandler constructs a TeamHandler. wh is used by Collapse to -// route StopWorkspaceAuto through the backend dispatcher. -func NewTeamHandler(b *events.Broadcaster, wh *WorkspaceHandler, platformURL, configsDir string) *TeamHandler { - return &TeamHandler{wh: wh, b: b} -} - -// Collapse handles POST /workspaces/:id/collapse -// Stops and removes all child workspaces. -func (h *TeamHandler) Collapse(c *gin.Context) { - parentID := c.Param("id") - ctx := c.Request.Context() - - // Find children - rows, err := db.DB.QueryContext(ctx, - `SELECT id, name FROM workspaces WHERE parent_id = $1 AND status != 'removed'`, parentID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query children"}) - return - } - defer rows.Close() - - removed := make([]string, 0) - for rows.Next() { - var childID, childName string - if rows.Scan(&childID, &childName) != nil { - continue - } - - // Stop the workload via the backend dispatcher (CP for SaaS, - // Docker for self-hosted). Pre-2026-05-05 this was - // `if h.provisioner != nil { h.provisioner.Stop(...) }`, which - // silently skipped on every SaaS tenant — child EC2s kept running - // after team-collapse until the orphan sweeper caught them - // (issue #2813). - if err := h.wh.StopWorkspaceAuto(ctx, childID); err != nil { - log.Printf("Team collapse: stop %s failed: %v — orphan sweeper will reconcile", childID, err) - } - - // Mark as removed - if _, err := db.DB.ExecContext(ctx, - `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusRemoved, childID); err != nil { - log.Printf("Team collapse: failed to remove workspace %s: %v", childID, err) - } - if _, err := db.DB.ExecContext(ctx, - `DELETE FROM canvas_layouts WHERE workspace_id = $1`, childID); err != nil { - log.Printf("Team collapse: failed to delete layout for %s: %v", childID, err) - } - - h.b.RecordAndBroadcast(ctx, "WORKSPACE_REMOVED", childID, map[string]interface{}{}) - - removed = append(removed, childName) - } - - h.b.RecordAndBroadcast(ctx, "WORKSPACE_COLLAPSED", parentID, map[string]interface{}{ - "removed_children": removed, - }) - - c.JSON(http.StatusOK, gin.H{ - "status": "collapsed", - "removed": removed, - }) -} - -// findTemplateDirByName resolves a workspace name to its template -// directory. Kept here because callers outside this package may use -// it, even though the in-package consumer (Expand) is gone. -// -// TODO: relocate alongside the templates handler if no other callers -// surface, or delete entirely after a deprecation cycle. -func findTemplateDirByName(configsDir, name string) string { - normalized := normalizeName(name) - - candidate := filepath.Join(configsDir, normalized) - if _, err := os.Stat(filepath.Join(candidate, "config.yaml")); err == nil { - return candidate - } - - // Fall back to scanning all dirs - entries, err := os.ReadDir(configsDir) - if err != nil { - return "" - } - for _, e := range entries { - if !e.IsDir() { - continue - } - cfgPath := filepath.Join(configsDir, e.Name(), "config.yaml") - data, err := os.ReadFile(cfgPath) - if err != nil { - continue - } - var cfg struct { - Name string `yaml:"name"` - } - if json.Unmarshal(data, &cfg) == nil && cfg.Name == name { - return filepath.Join(configsDir, e.Name()) - } - if yaml.Unmarshal(data, &cfg) == nil && cfg.Name == name { - return filepath.Join(configsDir, e.Name()) - } - } - return "" -} diff --git a/workspace-server/internal/handlers/team_test.go b/workspace-server/internal/handlers/team_test.go deleted file mode 100644 index e87a92ae..00000000 --- a/workspace-server/internal/handlers/team_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/gin-gonic/gin" -) - -// ---------- TeamHandler: Collapse ---------- - -func TestTeamCollapse_NoChildren(t *testing.T) { - mock := setupTestDB(t) - setupTestRedis(t) - broadcaster := newTestBroadcaster() - handler := NewTeamHandler(broadcaster, NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()), "http://localhost:8080", "/tmp/configs") - - // No children - mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id"). - WithArgs("ws-parent"). - WillReturnRows(sqlmock.NewRows([]string{"id", "name"})) - - // WORKSPACE_COLLAPSED broadcast - mock.ExpectExec("INSERT INTO structure_events"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-parent"}} - c.Request = httptest.NewRequest("POST", "/", nil) - - handler.Collapse(c) - - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - if resp["status"] != "collapsed" { - t.Errorf("expected status 'collapsed', got %v", resp["status"]) - } -} - -func TestTeamCollapse_WithChildren(t *testing.T) { - mock := setupTestDB(t) - setupTestRedis(t) - broadcaster := newTestBroadcaster() - handler := NewTeamHandler(broadcaster, NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()), "http://localhost:8080", "/tmp/configs") - - // Two children - mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id"). - WithArgs("ws-parent"). - WillReturnRows(sqlmock.NewRows([]string{"id", "name"}). - AddRow("child-1", "Worker A"). - AddRow("child-2", "Worker B")) - - // UPDATE + DELETE + broadcast for child-1 - mock.ExpectExec("UPDATE workspaces SET status ="). - WithArgs("child-1"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("DELETE FROM canvas_layouts"). - WithArgs("child-1"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("INSERT INTO structure_events"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - // UPDATE + DELETE + broadcast for child-2 - mock.ExpectExec("UPDATE workspaces SET status ="). - WithArgs("child-2"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("DELETE FROM canvas_layouts"). - WithArgs("child-2"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec("INSERT INTO structure_events"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - // WORKSPACE_COLLAPSED broadcast for parent - mock.ExpectExec("INSERT INTO structure_events"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-parent"}} - c.Request = httptest.NewRequest("POST", "/", nil) - - handler.Collapse(c) - - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - removed, ok := resp["removed"].([]interface{}) - if !ok || len(removed) != 2 { - t.Errorf("expected 2 removed children, got %v", resp["removed"]) - } -} -// ---------- findTemplateDirByName helper ---------- - -func TestFindTemplateDirByName_DirectMatch(t *testing.T) { - dir := t.TempDir() - subDir := filepath.Join(dir, "mybot") - os.MkdirAll(subDir, 0755) - os.WriteFile(filepath.Join(subDir, "config.yaml"), []byte("name: MyBot"), 0644) - - result := findTemplateDirByName(dir, "mybot") - if result != subDir { - t.Errorf("expected %s, got %s", subDir, result) - } -} - -func TestFindTemplateDirByName_NotFound(t *testing.T) { - dir := t.TempDir() - result := findTemplateDirByName(dir, "nonexistent") - if result != "" { - t.Errorf("expected empty string, got %s", result) - } -} - -func TestFindTemplateDirByName_InvalidConfigsDir(t *testing.T) { - result := findTemplateDirByName("/nonexistent/path", "anything") - if result != "" { - t.Errorf("expected empty string for invalid dir, got %s", result) - } -} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index d6d7b2d7..ae928f2f 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -243,13 +243,15 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // entire platform. Gated behind AdminAuth (issue #180). r.GET("/approvals/pending", middleware.AdminAuth(db.DB), apph.ListAll) - // Team handlers — Collapse only. The bulk-Expand path is gone: - // every workspace can have children via the regular CreateWorkspace - // flow with parent_id set, so a separate handler that bulk-creates - // from sub_workspaces (and was non-idempotent — calling it twice - // duplicated the team) earned its way out. - teamh := handlers.NewTeamHandler(broadcaster, wh, platformURL, configsDir) - wsAuth.POST("/collapse", teamh.Collapse) + // (TeamHandler is gone — #2864.) The visual canvas Collapse + // button calls PATCH /workspaces/:id { collapsed: true/false } + // (presentational toggle on canvas_layouts), NOT the destructive + // POST /collapse that stopped + removed children. The + // destructive route had zero UI callers (verified via grep + // across canvas/, scripts/, and the MCP tool registry — only + // docs referenced it). team.go + team_test.go + the route + // + helpers (findTemplateDirByName, NewTeamHandler) are + // deleted; visual collapse is unaffected. // Agents ah := handlers.NewAgentHandler(broadcaster)