molecule-core/platform/cmd/cli/doctor.go
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
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>
2026-04-13 11:55:37 -07:00

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)
}