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>
254 lines
6.9 KiB
Go
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 ""
|
|
}
|