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>
299 lines
8.1 KiB
Go
299 lines
8.1 KiB
Go
package bundle
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Export serializes a running workspace into a Bundle.
|
|
// dockerCli is optional — when provided, config is read from the running container's /configs volume.
|
|
func Export(ctx context.Context, workspaceID, configsDir string, dockerCli *client.Client) (*Bundle, error) {
|
|
// Fetch workspace record
|
|
var name, role, status string
|
|
var tier int
|
|
var agentCard []byte
|
|
var parentID *string
|
|
|
|
err := db.DB.QueryRowContext(ctx, `
|
|
SELECT name, COALESCE(role, ''), tier, status,
|
|
COALESCE(agent_card, 'null'::jsonb), parent_id
|
|
FROM workspaces WHERE id = $1
|
|
`, workspaceID).Scan(&name, &role, &tier, &status, &agentCard, &parentID)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("workspace %s not found", workspaceID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch workspace: %w", err)
|
|
}
|
|
|
|
// Parse agent card
|
|
var card interface{}
|
|
if err := json.Unmarshal(agentCard, &card); err != nil {
|
|
card = nil
|
|
}
|
|
|
|
b := &Bundle{
|
|
Schema: "1.0",
|
|
ID: workspaceID,
|
|
Name: name,
|
|
Description: role,
|
|
Tier: tier,
|
|
AgentCard: card,
|
|
Version: "1.0.0",
|
|
}
|
|
|
|
// Initialize slices/maps before loadFromConfigDir uses them
|
|
b.SubWorkspaces = []Bundle{}
|
|
b.Skills = []BundleSkill{}
|
|
b.Tools = []BundleTool{}
|
|
b.Prompts = map[string]string{}
|
|
|
|
// Try to read config from running container first, then fall back to host templates
|
|
loaded := false
|
|
if dockerCli != nil {
|
|
containerName := provisioner.ContainerName(workspaceID)
|
|
if err := b.loadFromContainer(ctx, dockerCli, containerName); err == nil {
|
|
loaded = true
|
|
}
|
|
}
|
|
if !loaded {
|
|
// Fallback: read from host-side template directory
|
|
configPath := findConfigDir(configsDir, name)
|
|
if configPath != "" {
|
|
b.loadFromConfigDir(configPath)
|
|
}
|
|
}
|
|
|
|
// Recursively export sub-workspaces
|
|
rows, err := db.DB.QueryContext(ctx,
|
|
`SELECT id FROM workspaces WHERE parent_id = $1 AND status != 'removed'`, workspaceID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var childID string
|
|
if rows.Scan(&childID) == nil {
|
|
childBundle, err := Export(ctx, childID, configsDir, dockerCli)
|
|
if err == nil {
|
|
b.SubWorkspaces = append(b.SubWorkspaces, *childBundle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
// execInContainer runs a command in a container and returns stdout.
|
|
func execInContainer(ctx context.Context, dockerCli *client.Client, containerName string, cmd []string) (string, error) {
|
|
execCfg := container.ExecOptions{
|
|
Cmd: cmd,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
}
|
|
execID, err := dockerCli.ContainerExecCreate(ctx, containerName, execCfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := dockerCli.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Close()
|
|
var stdout bytes.Buffer
|
|
if _, err := stdcopy.StdCopy(&stdout, io.Discard, io.LimitReader(resp.Reader, 5*1024*1024)); err != nil {
|
|
return "", fmt.Errorf("failed to read exec output: %w", err)
|
|
}
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// loadFromContainer reads config files from a running container's /configs directory.
|
|
func (b *Bundle) loadFromContainer(ctx context.Context, dockerCli *client.Client, containerName string) error {
|
|
// Check container is running
|
|
info, err := dockerCli.ContainerInspect(ctx, containerName)
|
|
if err != nil || !info.State.Running {
|
|
return fmt.Errorf("container not running")
|
|
}
|
|
|
|
// Read system-prompt.md
|
|
if content, err := execInContainer(ctx, dockerCli, containerName, []string{"cat", "/configs/system-prompt.md"}); err == nil {
|
|
b.SystemPrompt = content
|
|
}
|
|
|
|
// Read config.yaml
|
|
if content, err := execInContainer(ctx, dockerCli, containerName, []string{"cat", "/configs/config.yaml"}); err == nil {
|
|
b.Prompts["config.yaml"] = content
|
|
}
|
|
|
|
// Read skills
|
|
output, err := execInContainer(ctx, dockerCli, containerName, []string{"sh", "-c", "ls -1 /configs/skills/ 2>/dev/null || true"})
|
|
if err != nil || output == "" {
|
|
return nil
|
|
}
|
|
|
|
for _, skillName := range strings.Split(output, "\n") {
|
|
skillName = strings.TrimSpace(skillName)
|
|
if skillName == "" {
|
|
continue
|
|
}
|
|
skill := BundleSkill{
|
|
ID: skillName,
|
|
Name: skillName,
|
|
Files: map[string]string{},
|
|
}
|
|
|
|
// List files in skill directory
|
|
skillFiles, err := execInContainer(ctx, dockerCli, containerName, []string{
|
|
"find", "/configs/skills/" + skillName, "-type", "f",
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, filePath := range strings.Split(skillFiles, "\n") {
|
|
filePath = strings.TrimSpace(filePath)
|
|
if filePath == "" {
|
|
continue
|
|
}
|
|
relPath := strings.TrimPrefix(filePath, "/configs/skills/"+skillName+"/")
|
|
if content, err := execInContainer(ctx, dockerCli, containerName, []string{"cat", filePath}); err == nil {
|
|
skill.Files[relPath] = content
|
|
}
|
|
}
|
|
|
|
if content, ok := skill.Files["SKILL.md"]; ok {
|
|
skill.Description = extractDescription(content)
|
|
}
|
|
b.Skills = append(b.Skills, skill)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadFromConfigDir reads config files and skills from a workspace config directory.
|
|
func (b *Bundle) loadFromConfigDir(dir string) {
|
|
// Read system-prompt.md
|
|
if data, err := os.ReadFile(filepath.Join(dir, "system-prompt.md")); err == nil {
|
|
b.SystemPrompt = string(data)
|
|
}
|
|
|
|
// Read config.yaml for model/tools
|
|
if data, err := os.ReadFile(filepath.Join(dir, "config.yaml")); err == nil {
|
|
b.Prompts["config.yaml"] = string(data)
|
|
}
|
|
|
|
// Read skills
|
|
skillsDir := filepath.Join(dir, "skills")
|
|
entries, err := os.ReadDir(skillsDir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
skill := BundleSkill{
|
|
ID: entry.Name(),
|
|
Name: entry.Name(),
|
|
Files: map[string]string{},
|
|
}
|
|
|
|
// Walk all files in the skill directory
|
|
skillPath := filepath.Join(skillsDir, entry.Name())
|
|
filepath.Walk(skillPath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
relPath, _ := filepath.Rel(skillPath, path)
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
skill.Files[relPath] = string(data)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Extract description from SKILL.md if present
|
|
if content, ok := skill.Files["SKILL.md"]; ok {
|
|
skill.Description = extractDescription(content)
|
|
}
|
|
|
|
b.Skills = append(b.Skills, skill)
|
|
}
|
|
}
|
|
|
|
// findConfigDir tries to match a workspace name to a config directory.
|
|
// It checks for a directory whose config.yaml "name" field matches the workspace name,
|
|
// falling back to the first directory with a config.yaml if no name match is found.
|
|
func findConfigDir(configsDir, name string) string {
|
|
entries, err := os.ReadDir(configsDir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var fallback string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
configPath := filepath.Join(configsDir, e.Name(), "config.yaml")
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Check if the config name matches the workspace name
|
|
var cfg struct {
|
|
Name string `yaml:"name"`
|
|
}
|
|
if yaml.Unmarshal(data, &cfg) == nil && cfg.Name == name {
|
|
return filepath.Join(configsDir, e.Name())
|
|
}
|
|
if fallback == "" {
|
|
fallback = filepath.Join(configsDir, e.Name())
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// extractDescription pulls the first non-empty line after YAML frontmatter.
|
|
func extractDescription(content string) string {
|
|
inFrontmatter := false
|
|
for _, line := range splitLines(content) {
|
|
if line == "---" {
|
|
inFrontmatter = !inFrontmatter
|
|
continue
|
|
}
|
|
if !inFrontmatter && len(line) > 0 && line[0] != '#' {
|
|
return line
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|