molecule-core/workspace-server/internal/bundle/exporter.go
Hongming Wang d8026347e5 chore: open-source restructure — rename dirs, remove internal files, scrub secrets
Renames:
- platform/ → workspace-server/ (Go module path stays as "platform" for
  external dep compat — will update after plugin module republish)
- workspace-template/ → workspace/

Removed (moved to separate repos or deleted):
- PLAN.md — internal roadmap (move to private project board)
- HANDOFF.md, AGENTS.md — one-time internal session docs
- .claude/ — gitignored entirely (local agent config)
- infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy
- org-templates/molecule-dev/ → standalone template repo
- .mcp-eval/ → molecule-mcp-server repo
- test-results/ — ephemeral, gitignored

Security scrubbing:
- Cloudflare account/zone/KV IDs → placeholders
- Real EC2 IPs → <EC2_IP> in all docs
- CF token prefix, Neon project ID, Fly app names → redacted
- Langfuse dev credentials → parameterized
- Personal runner username/machine name → generic

Community files:
- CONTRIBUTING.md — build, test, branch conventions
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1

All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml,
README, CLAUDE.md updated for new directory names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 00:24:44 -07:00

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
}