molecule-core/platform/internal/handlers/team.go
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1)
with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo.

Brand: Starfire → Molecule AI.
Slug: starfire / agent-molecule → molecule.
Env vars: STARFIRE_* → MOLECULE_*.
Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform.
Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent.
DB: agentmolecule → molecule.

History truncated; see public repo for prior commits and contributor
attribution. Verified green: go test -race ./... (platform), pytest
(workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:55:37 -07:00

254 lines
6.9 KiB
Go

package handlers
import (
"context"
"database/sql"
"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/provisioner"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
type TeamHandler struct {
broadcaster *events.Broadcaster
provisioner *provisioner.Provisioner
platformURL string
configsDir string
}
func NewTeamHandler(b *events.Broadcaster, p *provisioner.Provisioner, platformURL, configsDir string) *TeamHandler {
return &TeamHandler{
broadcaster: b,
provisioner: p,
platformURL: platformURL,
configsDir: configsDir,
}
}
// Expand handles POST /workspaces/:id/expand
// Reads sub_workspaces from the workspace's config and provisions child workspaces.
func (h *TeamHandler) Expand(c *gin.Context) {
parentID := c.Param("id")
ctx := c.Request.Context()
// Verify workspace exists and is online
var name string
var tier int
var status string
err := db.DB.QueryRowContext(ctx,
`SELECT name, tier, status FROM workspaces WHERE id = $1`, parentID,
).Scan(&name, &tier, &status)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
return
}
// Find the workspace's config to get sub_workspaces
templateDir := findTemplateDirByName(h.configsDir, name)
if templateDir == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no config found for workspace"})
return
}
configData, err := os.ReadFile(filepath.Join(templateDir, "config.yaml"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read config"})
return
}
var config struct {
SubWorkspaces []struct {
Config string `yaml:"config"`
Name string `yaml:"name"`
Role string `yaml:"role"`
} `yaml:"sub_workspaces"`
}
if err := yaml.Unmarshal(configData, &config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse config"})
return
}
if len(config.SubWorkspaces) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace has no sub_workspaces defined in config"})
return
}
// Create child workspaces
children := make([]map[string]interface{}, 0)
for _, sub := range config.SubWorkspaces {
childID := uuid.New().String()
childName := sub.Name
if childName == "" {
childName = sub.Config
}
_, err := db.DB.ExecContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, status, parent_id)
VALUES ($1, $2, $3, $4, 'provisioning', $5)
`, childID, childName, nilStr(sub.Role), tier, parentID)
if err != nil {
log.Printf("Expand: failed to create child %s: %v", childName, err)
continue
}
// Insert canvas layout (offset from parent)
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO canvas_layouts (workspace_id, x, y) VALUES ($1, $2, $3)
`, childID, 0, 0); err != nil {
log.Printf("Team expand: failed to insert layout for child %s: %v", childID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", childID, map[string]interface{}{
"name": childName,
"tier": tier,
"parent_id": parentID,
})
// Provision if template exists
if h.provisioner != nil && sub.Config != "" {
templatePath := filepath.Join(h.configsDir, sub.Config)
if _, err := os.Stat(templatePath); err == nil {
pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
go func(wID, tPath, pPath string, t int) {
provCtx, cancel := context.WithTimeout(context.Background(), provisioner.ProvisionTimeout)
defer cancel()
cfg := provisioner.WorkspaceConfig{
WorkspaceID: wID,
TemplatePath: tPath,
PluginsPath: pPath,
Tier: t,
EnvVars: map[string]string{"PARENT_ID": parentID},
PlatformURL: h.platformURL,
}
if _, err := h.provisioner.Start(provCtx, cfg); err != nil {
log.Printf("Expand: provision failed for %s: %v", wID, err)
}
}(childID, templatePath, pluginsPath, tier)
}
}
children = append(children, map[string]interface{}{
"id": childID,
"name": childName,
"role": sub.Role,
})
}
// Mark parent as expanded
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_EXPANDED", parentID, map[string]interface{}{
"children": children,
})
c.JSON(http.StatusOK, gin.H{
"status": "expanded",
"children": children,
})
}
// 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 container if provisioner available
if h.provisioner != nil {
h.provisioner.Stop(ctx, childID)
}
// Mark as removed
if _, err := db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = 'removed', updated_at = now() WHERE id = $1`, 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.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_REMOVED", childID, map[string]interface{}{})
removed = append(removed, childName)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_COLLAPSED", parentID, map[string]interface{}{
"removed_children": removed,
})
c.JSON(http.StatusOK, gin.H{
"status": "collapsed",
"removed": removed,
})
}
func nilStr(s string) interface{} {
if s == "" {
return nil
}
return s
}
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())
}
// Try yaml unmarshal too
if yaml.Unmarshal(data, &cfg) == nil && cfg.Name == name {
return filepath.Join(configsDir, e.Name())
}
}
return ""
}