mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
* docs: add telegram async recovery design * feat(telegram): add async startup recovery * fix(telegram): resolve lint nits * fix(core): resolve errcheck lint issues * chore: fix launchd lint issues * test(telegram): wrap bot api fixtures for interface
509 lines
13 KiB
Go
509 lines
13 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type DoctorStatus int
|
|
|
|
const (
|
|
DoctorPass DoctorStatus = iota
|
|
DoctorWarn
|
|
DoctorFail
|
|
)
|
|
|
|
func (s DoctorStatus) Icon() string {
|
|
switch s {
|
|
case DoctorPass:
|
|
return "✅"
|
|
case DoctorWarn:
|
|
return "⚠️"
|
|
default:
|
|
return "❌"
|
|
}
|
|
}
|
|
|
|
type DoctorCheckResult struct {
|
|
Name string
|
|
Status DoctorStatus
|
|
Detail string
|
|
Latency time.Duration
|
|
}
|
|
|
|
// DoctorChecker is an optional interface for agents to provide specific health checks.
|
|
type DoctorChecker interface {
|
|
DoctorChecks(ctx context.Context) []DoctorCheckResult
|
|
}
|
|
|
|
// AgentDoctorInfo is an optional interface agents can implement to provide
|
|
// CLI binary name and display label for doctor checks, avoiding hardcoded
|
|
// agent-specific knowledge in core.
|
|
type AgentDoctorInfo interface {
|
|
CLIBinaryName() string // e.g. "claude", "codex"
|
|
CLIDisplayName() string // e.g. "Claude", "Codex" (for display in doctor output)
|
|
}
|
|
|
|
// RunDoctorChecks performs all diagnostic checks.
|
|
func RunDoctorChecks(ctx context.Context, agent Agent, platforms []Platform) []DoctorCheckResult {
|
|
var results []DoctorCheckResult
|
|
|
|
results = append(results, checkAgentBinary(ctx, agent)...)
|
|
results = append(results, checkAgentAuth(ctx, agent)...)
|
|
results = append(results, checkPlatforms(platforms)...)
|
|
results = append(results, checkSystem(ctx)...)
|
|
results = append(results, checkDependencies()...)
|
|
results = append(results, checkNetwork(ctx)...)
|
|
|
|
if dc, ok := agent.(DoctorChecker); ok {
|
|
results = append(results, dc.DoctorChecks(ctx)...)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func agentCLIInfo(agent Agent) (bin, label string) {
|
|
if info, ok := agent.(AgentDoctorInfo); ok {
|
|
bin = info.CLIBinaryName()
|
|
label = info.CLIDisplayName()
|
|
}
|
|
if bin == "" {
|
|
bin = agent.Name()
|
|
}
|
|
if label == "" {
|
|
label = bin
|
|
}
|
|
return bin, label
|
|
}
|
|
|
|
func checkAgentBinary(ctx context.Context, agent Agent) []DoctorCheckResult {
|
|
bin, _ := agentCLIInfo(agent)
|
|
|
|
path, err := exec.LookPath(bin)
|
|
if err != nil {
|
|
return []DoctorCheckResult{{
|
|
Name: fmt.Sprintf("Agent CLI (%s)", bin),
|
|
Status: DoctorFail,
|
|
Detail: "not found in PATH",
|
|
}}
|
|
}
|
|
|
|
detail := path
|
|
tctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
if out, err := exec.CommandContext(tctx, bin, "--version").Output(); err == nil {
|
|
ver := strings.TrimSpace(string(out))
|
|
if len(ver) > 80 {
|
|
ver = ver[:80]
|
|
}
|
|
detail = ver
|
|
}
|
|
return []DoctorCheckResult{{
|
|
Name: fmt.Sprintf("Agent CLI (%s)", bin),
|
|
Status: DoctorPass,
|
|
Detail: detail,
|
|
}}
|
|
}
|
|
|
|
func checkAgentAuth(ctx context.Context, agent Agent) []DoctorCheckResult {
|
|
bin, label := agentCLIInfo(agent)
|
|
return checkCLIAuth(ctx, bin, []string{"--version"}, label)
|
|
}
|
|
|
|
func checkCLIAuth(ctx context.Context, bin string, args []string, label string) []DoctorCheckResult {
|
|
tctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
cmd := exec.CommandContext(tctx, bin, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
latency := time.Since(start)
|
|
|
|
if err != nil {
|
|
detail := strings.TrimSpace(string(out))
|
|
if detail == "" {
|
|
detail = err.Error()
|
|
}
|
|
if len(detail) > 120 {
|
|
detail = detail[:120] + "..."
|
|
}
|
|
return []DoctorCheckResult{{
|
|
Name: fmt.Sprintf("%s Auth", label),
|
|
Status: DoctorWarn,
|
|
Detail: detail,
|
|
Latency: latency,
|
|
}}
|
|
}
|
|
return []DoctorCheckResult{{
|
|
Name: fmt.Sprintf("%s Auth", label),
|
|
Status: DoctorPass,
|
|
Detail: "OK",
|
|
Latency: latency,
|
|
}}
|
|
}
|
|
|
|
func checkPlatforms(platforms []Platform) []DoctorCheckResult {
|
|
var results []DoctorCheckResult
|
|
for _, p := range platforms {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: fmt.Sprintf("Platform (%s)", p.Name()),
|
|
Status: DoctorPass,
|
|
Detail: "connected",
|
|
})
|
|
}
|
|
if len(results) == 0 {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "Platforms",
|
|
Status: DoctorWarn,
|
|
Detail: "no platforms configured",
|
|
})
|
|
}
|
|
return results
|
|
}
|
|
|
|
func checkSystem(ctx context.Context) []DoctorCheckResult {
|
|
var results []DoctorCheckResult
|
|
|
|
// Memory
|
|
var memStats runtime.MemStats
|
|
runtime.ReadMemStats(&memStats)
|
|
allocMB := memStats.Alloc / 1024 / 1024
|
|
sysMB := memStats.Sys / 1024 / 1024
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "Memory (Go runtime)",
|
|
Status: DoctorPass,
|
|
Detail: fmt.Sprintf("alloc %d MB / sys %d MB", allocMB, sysMB),
|
|
})
|
|
|
|
// System memory (Linux)
|
|
if runtime.GOOS == "linux" {
|
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
|
var totalKB, availKB uint64
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if strings.HasPrefix(line, "MemTotal:") {
|
|
_, _ = fmt.Sscanf(line, "MemTotal: %d kB", &totalKB)
|
|
} else if strings.HasPrefix(line, "MemAvailable:") {
|
|
_, _ = fmt.Sscanf(line, "MemAvailable: %d kB", &availKB)
|
|
}
|
|
}
|
|
if totalKB > 0 {
|
|
totalMB := totalKB / 1024
|
|
availMB := availKB / 1024
|
|
usedPct := 100 - (availKB*100)/totalKB
|
|
status := DoctorPass
|
|
if usedPct > 90 {
|
|
status = DoctorFail
|
|
} else if usedPct > 75 {
|
|
status = DoctorWarn
|
|
}
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "System Memory",
|
|
Status: status,
|
|
Detail: fmt.Sprintf("%d MB available / %d MB total (%d%% used)", availMB, totalMB, usedPct),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// CPU
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "CPU",
|
|
Status: DoctorPass,
|
|
Detail: fmt.Sprintf("%d cores, %s/%s", runtime.NumCPU(), runtime.GOOS, runtime.GOARCH),
|
|
})
|
|
|
|
// Load average (Linux/macOS)
|
|
if runtime.GOOS == "linux" {
|
|
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
|
parts := strings.Fields(string(data))
|
|
if len(parts) >= 3 {
|
|
status := DoctorPass
|
|
detail := fmt.Sprintf("load avg: %s %s %s", parts[0], parts[1], parts[2])
|
|
// Rough check: if 1-min load > 2x CPU count, warn
|
|
var load1 float64
|
|
_, _ = fmt.Sscanf(parts[0], "%f", &load1)
|
|
if load1 > float64(runtime.NumCPU()*2) {
|
|
status = DoctorWarn
|
|
}
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "CPU Load",
|
|
Status: status,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Disk space
|
|
if wd, err := os.Getwd(); err == nil {
|
|
tctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
if out, err := exec.CommandContext(tctx, "df", "-h", wd).Output(); err == nil {
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
if len(lines) >= 2 {
|
|
fields := strings.Fields(lines[len(lines)-1])
|
|
if len(fields) >= 5 {
|
|
status := DoctorPass
|
|
usePct := strings.TrimSuffix(fields[4], "%")
|
|
var pct int
|
|
_, _ = fmt.Sscanf(usePct, "%d", &pct)
|
|
if pct > 95 {
|
|
status = DoctorFail
|
|
} else if pct > 85 {
|
|
status = DoctorWarn
|
|
}
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "Disk Space",
|
|
Status: status,
|
|
Detail: fmt.Sprintf("%s available / %s total (%s used)", fields[3], fields[1], fields[4]),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func checkDependencies() []DoctorCheckResult {
|
|
deps := []struct {
|
|
bin string
|
|
label string
|
|
required bool
|
|
}{
|
|
{"git", "Git", true},
|
|
{"sqlite3", "SQLite3", false},
|
|
{"ffmpeg", "FFmpeg (voice)", false},
|
|
}
|
|
|
|
var results []DoctorCheckResult
|
|
for _, d := range deps {
|
|
path, err := exec.LookPath(d.bin)
|
|
if err != nil {
|
|
status := DoctorWarn
|
|
if d.required {
|
|
status = DoctorFail
|
|
}
|
|
results = append(results, DoctorCheckResult{
|
|
Name: d.label,
|
|
Status: status,
|
|
Detail: "not found",
|
|
})
|
|
} else {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: d.label,
|
|
Status: DoctorPass,
|
|
Detail: path,
|
|
})
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
func checkNetwork(ctx context.Context) []DoctorCheckResult {
|
|
endpoints := []struct {
|
|
label string
|
|
host string
|
|
url string
|
|
}{
|
|
{"API (Anthropic)", "api.anthropic.com:443", "https://api.anthropic.com"},
|
|
{"API (OpenAI)", "api.openai.com:443", "https://api.openai.com"},
|
|
}
|
|
|
|
var results []DoctorCheckResult
|
|
for _, ep := range endpoints {
|
|
tctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
start := time.Now()
|
|
conn, err := (&net.Dialer{}).DialContext(tctx, "tcp", ep.host)
|
|
latency := time.Since(start)
|
|
cancel()
|
|
|
|
if err != nil {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: ep.label,
|
|
Status: DoctorWarn,
|
|
Detail: fmt.Sprintf("connect failed: %v", err),
|
|
Latency: latency,
|
|
})
|
|
continue
|
|
}
|
|
conn.Close()
|
|
|
|
status := DoctorPass
|
|
if latency > 3*time.Second {
|
|
status = DoctorWarn
|
|
}
|
|
results = append(results, DoctorCheckResult{
|
|
Name: ep.label,
|
|
Status: status,
|
|
Detail: "TCP connect OK",
|
|
Latency: latency,
|
|
})
|
|
}
|
|
|
|
// HTTP check to verify proxy/firewall
|
|
tctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
|
defer cancel()
|
|
start := time.Now()
|
|
client := &http.Client{Timeout: 8 * time.Second}
|
|
req, _ := http.NewRequestWithContext(tctx, "HEAD", "https://api.anthropic.com", nil)
|
|
resp, err := client.Do(req)
|
|
latency := time.Since(start)
|
|
if err != nil {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "HTTPS (Anthropic)",
|
|
Status: DoctorWarn,
|
|
Detail: "HTTPS request failed: " + err.Error(),
|
|
Latency: latency,
|
|
})
|
|
} else {
|
|
resp.Body.Close()
|
|
status := DoctorPass
|
|
if latency > 5*time.Second {
|
|
status = DoctorWarn
|
|
}
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "HTTPS (Anthropic)",
|
|
Status: status,
|
|
Detail: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
|
Latency: latency,
|
|
})
|
|
}
|
|
|
|
// Check config file
|
|
if cfgPath := os.Getenv("CC_CONFIG_PATH"); cfgPath != "" {
|
|
if _, err := os.Stat(cfgPath); err != nil {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "Config File",
|
|
Status: DoctorFail,
|
|
Detail: cfgPath + ": " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Check data directory
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
dataDir := filepath.Join(home, ".cc-connect")
|
|
if info, err := os.Stat(dataDir); err != nil {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "Data Directory",
|
|
Status: DoctorWarn,
|
|
Detail: dataDir + " does not exist",
|
|
})
|
|
} else if !info.IsDir() {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "Data Directory",
|
|
Status: DoctorFail,
|
|
Detail: dataDir + " is not a directory",
|
|
})
|
|
} else {
|
|
results = append(results, DoctorCheckResult{
|
|
Name: "Data Directory",
|
|
Status: DoctorPass,
|
|
Detail: dataDir,
|
|
})
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// checkNameZh provides Chinese translations for common check names.
|
|
var checkNameZh = map[string]string{
|
|
"Memory (Go runtime)": "内存 (Go runtime)",
|
|
"System Memory": "系统内存",
|
|
"CPU": "CPU",
|
|
"CPU Load": "CPU 负载",
|
|
"Disk Space": "磁盘空间",
|
|
"Git": "Git",
|
|
"SQLite3": "SQLite3",
|
|
"FFmpeg (voice)": "FFmpeg (语音)",
|
|
"HTTPS (Anthropic)": "HTTPS (Anthropic)",
|
|
"Data Directory": "数据目录",
|
|
"Config File": "配置文件",
|
|
"Platforms": "平台",
|
|
}
|
|
|
|
// checkNameJa provides Japanese translations for common check names.
|
|
var checkNameJa = map[string]string{
|
|
"Memory (Go runtime)": "メモリ (Go runtime)",
|
|
"System Memory": "システムメモリ",
|
|
"CPU Load": "CPU 負荷",
|
|
"Disk Space": "ディスク容量",
|
|
"FFmpeg (voice)": "FFmpeg (音声)",
|
|
"Data Directory": "データディレクトリ",
|
|
"Config File": "設定ファイル",
|
|
"Platforms": "プラットフォーム",
|
|
}
|
|
|
|
func localizeCheckName(name string, lang Language) string {
|
|
switch lang {
|
|
case LangChinese, LangTraditionalChinese:
|
|
// Translate known names; parametric names (e.g. "Agent CLI (claude)") need prefix matching
|
|
if zh, ok := checkNameZh[name]; ok {
|
|
return zh
|
|
}
|
|
if strings.HasPrefix(name, "Agent CLI") {
|
|
return strings.Replace(name, "Agent CLI", "Agent 命令行", 1)
|
|
}
|
|
if strings.HasPrefix(name, "Platform (") {
|
|
return strings.Replace(name, "Platform", "平台", 1)
|
|
}
|
|
if strings.Contains(name, "Auth") {
|
|
return strings.Replace(name, "Auth", "认证", 1)
|
|
}
|
|
case LangJapanese:
|
|
if ja, ok := checkNameJa[name]; ok {
|
|
return ja
|
|
}
|
|
if strings.HasPrefix(name, "Agent CLI") {
|
|
return strings.Replace(name, "Agent CLI", "Agent CLI", 1)
|
|
}
|
|
if strings.HasPrefix(name, "Platform (") {
|
|
return strings.Replace(name, "Platform", "プラットフォーム", 1)
|
|
}
|
|
if strings.Contains(name, "Auth") {
|
|
return strings.Replace(name, "Auth", "認証", 1)
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
|
|
// FormatDoctorResults formats check results using the i18n system.
|
|
func FormatDoctorResults(results []DoctorCheckResult, i18n *I18n) string {
|
|
lang := i18n.currentLang()
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(i18n.T(MsgDoctorTitle))
|
|
|
|
passCount, warnCount, failCount := 0, 0, 0
|
|
for _, r := range results {
|
|
switch r.Status {
|
|
case DoctorPass:
|
|
passCount++
|
|
case DoctorWarn:
|
|
warnCount++
|
|
case DoctorFail:
|
|
failCount++
|
|
}
|
|
|
|
displayName := localizeCheckName(r.Name, lang)
|
|
latStr := ""
|
|
if r.Latency > 0 {
|
|
latStr = fmt.Sprintf(" (%s)", r.Latency.Round(time.Millisecond))
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%s %s%s\n %s\n\n", r.Status.Icon(), displayName, latStr, r.Detail))
|
|
}
|
|
|
|
sb.WriteString(i18n.Tf(MsgDoctorSummary, passCount, warnCount, failCount))
|
|
return sb.String()
|
|
}
|