Files
molecule-core/workspace-server/internal/bundle/importer.go
T
claude-ceo-assistant f7e2976324
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
Check migration collisions / Migration version collision check (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 33s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 50s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 58s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m6s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m25s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
E2E Chat / E2E Chat (pull_request) Successful in 33s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m58s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m44s
Harness Replays / Harness Replays (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 6m9s
CI / Canvas (Next.js) (pull_request) Successful in 7m41s
CI / all-required (pull_request) Successful in 32m0s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 32s
chore: retire unmaintained workspace runtimes
2026-05-23 23:45:09 -07:00

156 lines
4.8 KiB
Go

package bundle
import (
"context"
"fmt"
"strings"
"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/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/google/uuid"
)
// ImportResult tracks the outcome of importing a bundle tree.
type ImportResult struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Status string `json:"status"` // "provisioning" or "failed"
Error string `json:"error,omitempty"`
Children []ImportResult `json:"children,omitempty"`
}
// Import provisions a workspace tree from a Bundle.
// It creates workspace records, writes config files to a temp dir, and triggers the provisioner.
func Import(
ctx context.Context,
b *Bundle,
parentID *string,
broadcaster *events.Broadcaster,
prov *provisioner.Provisioner,
platformURL string,
) ImportResult {
// Generate fresh workspace ID
wsID := uuid.New().String()
result := ImportResult{
WorkspaceID: wsID,
Name: b.Name,
Status: "provisioning",
}
// Create workspace record
_, err := db.DB.ExecContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, status, parent_id, source_bundle_id)
VALUES ($1, $2, $3, $4, 'provisioning', $5, $6)
`, wsID, b.Name, nilIfEmpty(b.Description), b.Tier, parentID, b.ID)
if err != nil {
result.Status = "failed"
result.Error = fmt.Sprintf("failed to create workspace record: %v", err)
return result
}
_ = broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), wsID, map[string]interface{}{
"name": b.Name,
"tier": b.Tier,
"source_bundle_id": b.ID,
})
// Build config files in memory for the provisioner
configFiles := buildBundleConfigFiles(b)
// Extract runtime from config.yaml in the bundle.
bundleRuntime := "claude-code"
if configYaml, ok := b.Prompts["config.yaml"]; ok {
for _, line := range strings.Split(configYaml, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "runtime:") {
bundleRuntime = strings.TrimSpace(strings.TrimPrefix(line, "runtime:"))
break
}
}
}
// Store runtime in DB
_, _ = db.DB.ExecContext(ctx, `UPDATE workspaces SET runtime = $1 WHERE id = $2`, bundleRuntime, wsID)
// Provision the container if provisioner is available
if prov != nil {
cfg := provisioner.WorkspaceConfig{
WorkspaceID: wsID,
ConfigFiles: configFiles,
Tier: b.Tier,
Runtime: bundleRuntime,
EnvVars: map[string]string{},
PlatformURL: platformURL,
// PluginsPath set by caller if available
}
go func() {
provCtx, cancel := context.WithTimeout(context.Background(), provisioner.ProvisionTimeout)
defer cancel()
url, err := prov.Start(provCtx, cfg)
if err != nil {
markFailed(provCtx, wsID, broadcaster, err)
} else if url != "" {
db.DB.ExecContext(provCtx, `UPDATE workspaces SET url = $1 WHERE id = $2`, url, wsID)
}
}()
}
// Recursively import sub-workspaces
for _, sub := range b.SubWorkspaces {
childResult := Import(ctx, &sub, &wsID, broadcaster, prov, platformURL)
result.Children = append(result.Children, childResult)
}
return result
}
// buildBundleConfigFiles builds a map of config files from a bundle for writing into a container volume.
func buildBundleConfigFiles(b *Bundle) map[string][]byte {
files := make(map[string][]byte)
// Write system-prompt.md
if b.SystemPrompt != "" {
files["system-prompt.md"] = []byte(b.SystemPrompt)
}
// Write config.yaml from prompts if present
if configYaml, ok := b.Prompts["config.yaml"]; ok {
files["config.yaml"] = []byte(configYaml)
}
// Write skills
for _, skill := range b.Skills {
for relPath, content := range skill.Files {
files[fmt.Sprintf("skills/%s/%s", skill.ID, relPath)] = []byte(content)
}
}
return files
}
func markFailed(ctx context.Context, wsID string, broadcaster *events.Broadcaster, err error) {
// Set last_sample_error along with status so operators (and the
// Canvas E2E + GET /workspaces/:id callers) get a non-null reason
// in the row. Pre-2026-05-05 this UPDATE only set status, leaving
// last_sample_error NULL — Canvas E2E #2632 surfaced the gap with
// `Workspace failed: (no last_sample_error)`. Same UPDATE shape as
// markProvisionFailed in workspace-server/internal/handlers/
// workspace_provision_shared.go.
msg := err.Error()
db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $1, last_sample_error = $2, updated_at = now() WHERE id = $3`,
models.StatusFailed, msg, wsID)
broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), wsID, map[string]interface{}{
"error": msg,
})
}
func nilIfEmpty(s string) interface{} {
if s == "" {
return nil
}
return s
}