Silent data loss on mid-cursor DB errors — partial sub-workspace bundles returned instead of surfacing the iteration error. Adds rows.Err() check after the SELECT id FROM workspaces query in Export(), mirroring the pattern already used in scheduler.go and handlers with similar recursion patterns. Closes: R1 MISSING-ROWS-ERR findings (bundle/exporter.go) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
302 lines
8.1 KiB
Go
302 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)
|
|
}
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("export sub-workspaces: %w", err)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|