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>
410 lines
11 KiB
Go
410 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/lib/pq"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
type DoctorStatus string
|
|
|
|
const (
|
|
DoctorStatusPass DoctorStatus = "PASS"
|
|
DoctorStatusWarn DoctorStatus = "WARN"
|
|
DoctorStatusFail DoctorStatus = "FAIL"
|
|
)
|
|
|
|
type DoctorResult struct {
|
|
Name string `json:"name"`
|
|
Status DoctorStatus `json:"status"`
|
|
Summary string `json:"summary"`
|
|
Fix string `json:"fix,omitempty"`
|
|
}
|
|
|
|
type DoctorSummary struct {
|
|
PassCount int `json:"pass_count"`
|
|
WarnCount int `json:"warn_count"`
|
|
FailCount int `json:"fail_count"`
|
|
HasFailures bool `json:"has_failures"`
|
|
}
|
|
|
|
type DoctorReport struct {
|
|
BaseURL string `json:"base_url"`
|
|
Results []DoctorResult `json:"results"`
|
|
Summary DoctorSummary `json:"summary"`
|
|
}
|
|
|
|
type doctorCheck struct {
|
|
Name string
|
|
Run func(context.Context) DoctorResult
|
|
}
|
|
|
|
func runDoctor(ctx context.Context, baseURL string) DoctorReport {
|
|
results := make([]DoctorResult, 0, 6)
|
|
for _, check := range buildDoctorChecks(baseURL) {
|
|
results = append(results, check.Run(ctx))
|
|
}
|
|
return DoctorReport{
|
|
BaseURL: baseURL,
|
|
Results: results,
|
|
Summary: summarizeDoctorResults(results),
|
|
}
|
|
}
|
|
|
|
func summarizeDoctorResults(results []DoctorResult) DoctorSummary {
|
|
var summary DoctorSummary
|
|
for _, result := range results {
|
|
switch result.Status {
|
|
case DoctorStatusPass:
|
|
summary.PassCount++
|
|
case DoctorStatusWarn:
|
|
summary.WarnCount++
|
|
case DoctorStatusFail:
|
|
summary.FailCount++
|
|
}
|
|
}
|
|
summary.HasFailures = summary.FailCount > 0
|
|
return summary
|
|
}
|
|
|
|
func buildDoctorChecks(baseURL string) []doctorCheck {
|
|
return []doctorCheck{
|
|
{Name: "Platform health", Run: func(ctx context.Context) DoctorResult { return checkPlatformHealth(ctx, baseURL) }},
|
|
{Name: "Postgres connection", Run: checkPostgres},
|
|
{Name: "Redis connection", Run: checkRedis},
|
|
{Name: "Platform migrations", Run: checkMigrationsDir},
|
|
{Name: "Workspace templates", Run: checkTemplatesDir},
|
|
{Name: "Docker / provisioner", Run: checkDocker},
|
|
}
|
|
}
|
|
|
|
func checkPlatformHealth(ctx context.Context, baseURL string) DoctorResult {
|
|
result := DoctorResult{Name: "Platform health"}
|
|
endpoint, err := url.JoinPath(baseURL, "health")
|
|
if err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Could not build %s health URL", baseURL)
|
|
result.Fix = "Check MOLECLI_URL and make sure it is a valid platform base URL."
|
|
return result
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Could not create request for %s", endpoint)
|
|
result.Fix = "Check MOLECLI_URL and try again."
|
|
return result
|
|
}
|
|
|
|
resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req)
|
|
if err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("GET %s failed: %v", endpoint, err)
|
|
result.Fix = "Start the platform server or point MOLECLI_URL at a running instance."
|
|
return result
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("GET %s returned %s", endpoint, resp.Status)
|
|
result.Fix = strings.TrimSpace(string(body))
|
|
if result.Fix == "" {
|
|
result.Fix = "Check platform logs and confirm the server is healthy."
|
|
}
|
|
return result
|
|
}
|
|
|
|
result.Status = DoctorStatusPass
|
|
result.Summary = fmt.Sprintf("GET %s responded OK", endpoint)
|
|
result.Fix = ""
|
|
return result
|
|
}
|
|
|
|
func checkPostgres(ctx context.Context) DoctorResult {
|
|
result := DoctorResult{Name: "Postgres connection"}
|
|
dsn := envOrLocal("DATABASE_URL", "postgres://dev:dev@localhost:5432/molecule?sslmode=disable")
|
|
|
|
db, err := sql.Open("postgres", dsn)
|
|
if err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Could not open DATABASE_URL: %v", err)
|
|
result.Fix = "Check DATABASE_URL and make sure Postgres is installed and reachable."
|
|
return result
|
|
}
|
|
defer db.Close()
|
|
|
|
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(pingCtx); err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Could not connect using DATABASE_URL: %v", err)
|
|
result.Fix = "Start infra with ./infra/scripts/setup.sh or update DATABASE_URL."
|
|
return result
|
|
}
|
|
|
|
result.Status = DoctorStatusPass
|
|
result.Summary = "Postgres is reachable with DATABASE_URL"
|
|
return result
|
|
}
|
|
|
|
func checkRedis(ctx context.Context) DoctorResult {
|
|
result := DoctorResult{Name: "Redis connection"}
|
|
rawURL := envOrLocal("REDIS_URL", "redis://localhost:6379")
|
|
opts, err := redis.ParseURL(rawURL)
|
|
if err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Could not parse REDIS_URL: %v", err)
|
|
result.Fix = "Check REDIS_URL and use a valid redis:// URL."
|
|
return result
|
|
}
|
|
|
|
client := redis.NewClient(opts)
|
|
defer client.Close()
|
|
|
|
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
if err := client.Ping(pingCtx).Err(); err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Redis is not reachable at %s: %v", rawURL, err)
|
|
result.Fix = "Start infra with ./infra/scripts/setup.sh or update REDIS_URL."
|
|
return result
|
|
}
|
|
|
|
result.Status = DoctorStatusPass
|
|
result.Summary = fmt.Sprintf("Redis is reachable at %s", rawURL)
|
|
return result
|
|
}
|
|
|
|
func checkTemplatesDir(ctx context.Context) DoctorResult {
|
|
_ = ctx
|
|
result := DoctorResult{Name: "Workspace templates"}
|
|
dir := findDoctorConfigsDir([]string{
|
|
"workspace-configs-templates",
|
|
"../workspace-configs-templates",
|
|
"../../workspace-configs-templates",
|
|
})
|
|
|
|
info, err := os.Stat(dir)
|
|
if err != nil || !info.IsDir() {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Workspace template directory not found: %s", dir)
|
|
result.Fix = "Run molecli from the repo or restore workspace-configs-templates/."
|
|
return result
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Could not read workspace template directory: %v", err)
|
|
result.Fix = "Check filesystem permissions for workspace-configs-templates/."
|
|
return result
|
|
}
|
|
|
|
var templateCount int
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, entry.Name(), "config.yaml")); err == nil {
|
|
templateCount++
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case templateCount == 0:
|
|
result.Status = DoctorStatusWarn
|
|
result.Summary = fmt.Sprintf("Template directory exists at %s but has no template config.yaml files", dir)
|
|
result.Fix = "Add or restore at least one template before creating new workspaces."
|
|
default:
|
|
result.Status = DoctorStatusPass
|
|
result.Summary = fmt.Sprintf("Found %d template(s) in %s", templateCount, dir)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func checkMigrationsDir(ctx context.Context) DoctorResult {
|
|
_ = ctx
|
|
result := DoctorResult{Name: "Platform migrations"}
|
|
dir := findDoctorMigrationsDir([]string{
|
|
"migrations",
|
|
"platform/migrations",
|
|
"../migrations",
|
|
"../../migrations",
|
|
})
|
|
|
|
if dir == "" {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = "Could not find a platform migrations directory"
|
|
result.Fix = "Run molecli from the repo root or restore platform/migrations."
|
|
return result
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("Could not read migrations directory: %v", err)
|
|
result.Fix = "Check filesystem permissions for the migrations directory."
|
|
return result
|
|
}
|
|
|
|
var sqlCount int
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(entry.Name(), ".sql") {
|
|
sqlCount++
|
|
}
|
|
}
|
|
|
|
if sqlCount == 0 {
|
|
result.Status = DoctorStatusWarn
|
|
result.Summary = fmt.Sprintf("Migrations directory exists at %s but has no .sql files", dir)
|
|
result.Fix = "Restore the platform migration files before starting the server."
|
|
return result
|
|
}
|
|
|
|
result.Status = DoctorStatusPass
|
|
result.Summary = fmt.Sprintf("Found %d migration file(s) in %s", sqlCount, dir)
|
|
return result
|
|
}
|
|
|
|
func checkDocker(ctx context.Context) DoctorResult {
|
|
result := DoctorResult{Name: "Docker / provisioner"}
|
|
if _, err := exec.LookPath("docker"); err != nil {
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = "docker command not found in PATH"
|
|
result.Fix = "Install Docker Desktop or make docker available in PATH."
|
|
return result
|
|
}
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(cmdCtx, "docker", "info")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
msg := strings.TrimSpace(string(output))
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
result.Status = DoctorStatusFail
|
|
result.Summary = fmt.Sprintf("docker info failed: %s", msg)
|
|
result.Fix = "Start Docker Desktop or fix docker daemon access before provisioning workspaces."
|
|
return result
|
|
}
|
|
|
|
result.Status = DoctorStatusPass
|
|
result.Summary = "docker info succeeded"
|
|
return result
|
|
}
|
|
|
|
func findDoctorConfigsDir(candidates []string) string {
|
|
for _, candidate := range candidates {
|
|
info, err := os.Stat(candidate)
|
|
if err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
|
|
entries, _ := os.ReadDir(candidate)
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(filepath.Join(candidate, entry.Name(), "config.yaml")); err == nil {
|
|
abs, _ := filepath.Abs(candidate)
|
|
return abs
|
|
}
|
|
}
|
|
}
|
|
return "workspace-configs-templates"
|
|
}
|
|
|
|
func findDoctorMigrationsDir(candidates []string) string {
|
|
for _, candidate := range candidates {
|
|
info, err := os.Stat(candidate)
|
|
if err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
abs, _ := filepath.Abs(candidate)
|
|
return abs
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func envOrLocal(key, fallback string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func printDoctorReport(report DoctorReport) {
|
|
fmt.Println("Molecule AI Doctor")
|
|
fmt.Println()
|
|
|
|
for _, result := range report.Results {
|
|
fmt.Printf("[%s] %s\n", result.Status, result.Name)
|
|
fmt.Printf(" %s\n", result.Summary)
|
|
if result.Fix != "" {
|
|
fmt.Printf(" Fix: %s\n", result.Fix)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
fmt.Println(doctorNextStep(report))
|
|
}
|
|
|
|
func doctorNextStep(report DoctorReport) string {
|
|
switch {
|
|
case report.Summary.HasFailures:
|
|
return "Next: Fix FAIL items first, then rerun `molecli doctor`."
|
|
case report.Summary.WarnCount > 0:
|
|
return "Next: Review warnings before provisioning new workspaces."
|
|
default:
|
|
return "Next: Environment looks good. You can start the platform and Canvas, then deploy a workspace template."
|
|
}
|
|
}
|
|
|
|
type exitCoder interface {
|
|
error
|
|
ExitCode() int
|
|
}
|
|
|
|
type cliExitError struct {
|
|
code int
|
|
msg string
|
|
}
|
|
|
|
func (e *cliExitError) Error() string {
|
|
return e.msg
|
|
}
|
|
|
|
func (e *cliExitError) ExitCode() int {
|
|
return e.code
|
|
}
|
|
|
|
func newCLIExitError(code int, msg string) error {
|
|
if code == 0 {
|
|
return nil
|
|
}
|
|
return &cliExitError{code: code, msg: msg}
|
|
}
|
|
|
|
func isCLIExitError(err error) bool {
|
|
var exitErr exitCoder
|
|
return errors.As(err, &exitErr)
|
|
}
|