Add gt doctor checks for ephemeral beads repos: - ephemeral-exists: Verify .beads-ephemeral/ exists for each rig (fixable) - ephemeral-git: Verify it's a valid git repo (fixable) - ephemeral-orphans: Detect molecules >24h old (needs manual review) - ephemeral-size: Warn if repo >100MB - ephemeral-stale: Detect molecules with no activity in last hour Implements gt-3x0z.3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
520 lines
13 KiB
Go
520 lines
13 KiB
Go
package doctor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
)
|
|
|
|
// EphemeralExistsCheck verifies that .beads-ephemeral/ exists for each rig.
|
|
type EphemeralExistsCheck struct {
|
|
FixableCheck
|
|
missingRigs []string // Cached for fix
|
|
}
|
|
|
|
// NewEphemeralExistsCheck creates a new ephemeral exists check.
|
|
func NewEphemeralExistsCheck() *EphemeralExistsCheck {
|
|
return &EphemeralExistsCheck{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "ephemeral-exists",
|
|
CheckDescription: "Check if ephemeral beads directory exists for each rig",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks if .beads-ephemeral/ exists for each rig.
|
|
func (c *EphemeralExistsCheck) Run(ctx *CheckContext) *CheckResult {
|
|
c.missingRigs = nil // Reset cache
|
|
|
|
// Find all rigs
|
|
rigs, err := c.discoverRigs(ctx.TownRoot)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: "Failed to discover rigs",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
if len(rigs) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No rigs configured",
|
|
}
|
|
}
|
|
|
|
// Check each rig
|
|
var missing []string
|
|
for _, rigName := range rigs {
|
|
ephemeralPath := filepath.Join(ctx.TownRoot, rigName, ".beads-ephemeral")
|
|
if _, err := os.Stat(ephemeralPath); os.IsNotExist(err) {
|
|
missing = append(missing, rigName)
|
|
}
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
c.missingRigs = missing
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d rig(s) missing ephemeral beads directory", len(missing)),
|
|
Details: missing,
|
|
FixHint: "Run 'gt doctor --fix' to create missing directories",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("All %d rig(s) have ephemeral beads directory", len(rigs)),
|
|
}
|
|
}
|
|
|
|
// Fix creates missing .beads-ephemeral/ directories.
|
|
func (c *EphemeralExistsCheck) Fix(ctx *CheckContext) error {
|
|
for _, rigName := range c.missingRigs {
|
|
ephemeralPath := filepath.Join(ctx.TownRoot, rigName, ".beads-ephemeral")
|
|
if err := os.MkdirAll(ephemeralPath, 0755); err != nil {
|
|
return fmt.Errorf("creating %s: %w", ephemeralPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// discoverRigs finds all registered rigs.
|
|
func (c *EphemeralExistsCheck) discoverRigs(townRoot string) ([]string, error) {
|
|
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
data, err := os.ReadFile(rigsPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil // No rigs configured
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var rigsConfig config.RigsConfig
|
|
if err := json.Unmarshal(data, &rigsConfig); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rigs []string
|
|
for name := range rigsConfig.Rigs {
|
|
rigs = append(rigs, name)
|
|
}
|
|
return rigs, nil
|
|
}
|
|
|
|
// EphemeralGitCheck verifies that .beads-ephemeral/ is a valid git repo.
|
|
type EphemeralGitCheck struct {
|
|
FixableCheck
|
|
invalidRigs []string // Cached for fix
|
|
}
|
|
|
|
// NewEphemeralGitCheck creates a new ephemeral git check.
|
|
func NewEphemeralGitCheck() *EphemeralGitCheck {
|
|
return &EphemeralGitCheck{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "ephemeral-git",
|
|
CheckDescription: "Check if ephemeral beads directories are valid git repos",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks if .beads-ephemeral/ directories are valid git repos.
|
|
func (c *EphemeralGitCheck) Run(ctx *CheckContext) *CheckResult {
|
|
c.invalidRigs = nil // Reset cache
|
|
|
|
// Find all rigs
|
|
rigs, err := discoverRigs(ctx.TownRoot)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: "Failed to discover rigs",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
if len(rigs) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No rigs configured",
|
|
}
|
|
}
|
|
|
|
// Check each rig that has an ephemeral dir
|
|
var invalid []string
|
|
var checked int
|
|
for _, rigName := range rigs {
|
|
ephemeralPath := filepath.Join(ctx.TownRoot, rigName, ".beads-ephemeral")
|
|
if _, err := os.Stat(ephemeralPath); os.IsNotExist(err) {
|
|
continue // Skip if directory doesn't exist (handled by ephemeral-exists)
|
|
}
|
|
checked++
|
|
|
|
// Check if it's a valid git repo
|
|
gitDir := filepath.Join(ephemeralPath, ".git")
|
|
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
|
invalid = append(invalid, rigName)
|
|
}
|
|
}
|
|
|
|
if checked == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No ephemeral beads directories to check",
|
|
}
|
|
}
|
|
|
|
if len(invalid) > 0 {
|
|
c.invalidRigs = invalid
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d ephemeral beads directory(ies) not initialized as git", len(invalid)),
|
|
Details: invalid,
|
|
FixHint: "Run 'gt doctor --fix' to initialize git repos",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: fmt.Sprintf("All %d ephemeral beads directories are valid git repos", checked),
|
|
}
|
|
}
|
|
|
|
// Fix initializes git repos in ephemeral directories.
|
|
func (c *EphemeralGitCheck) Fix(ctx *CheckContext) error {
|
|
for _, rigName := range c.invalidRigs {
|
|
ephemeralPath := filepath.Join(ctx.TownRoot, rigName, ".beads-ephemeral")
|
|
cmd := exec.Command("git", "init")
|
|
cmd.Dir = ephemeralPath
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("initializing git in %s: %w", ephemeralPath, err)
|
|
}
|
|
|
|
// Create config.yaml for ephemeral beads
|
|
configPath := filepath.Join(ephemeralPath, "config.yaml")
|
|
configContent := "ephemeral: true\n# No sync-branch - ephemeral is local only\n"
|
|
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
|
return fmt.Errorf("creating config.yaml in %s: %w", ephemeralPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EphemeralOrphansCheck detects molecules started but never squashed (>24h old).
|
|
type EphemeralOrphansCheck struct {
|
|
BaseCheck
|
|
}
|
|
|
|
// NewEphemeralOrphansCheck creates a new ephemeral orphans check.
|
|
func NewEphemeralOrphansCheck() *EphemeralOrphansCheck {
|
|
return &EphemeralOrphansCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "ephemeral-orphans",
|
|
CheckDescription: "Check for orphaned molecules (>24h old, never squashed)",
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks for orphaned molecules.
|
|
func (c *EphemeralOrphansCheck) Run(ctx *CheckContext) *CheckResult {
|
|
rigs, err := discoverRigs(ctx.TownRoot)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: "Failed to discover rigs",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
if len(rigs) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No rigs configured",
|
|
}
|
|
}
|
|
|
|
var orphans []string
|
|
cutoff := time.Now().Add(-24 * time.Hour)
|
|
|
|
for _, rigName := range rigs {
|
|
ephemeralPath := filepath.Join(ctx.TownRoot, rigName, ".beads-ephemeral")
|
|
if _, err := os.Stat(ephemeralPath); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
// Look for molecule directories or issue files older than 24h
|
|
issuesPath := filepath.Join(ephemeralPath, "issues.jsonl")
|
|
info, err := os.Stat(issuesPath)
|
|
if err != nil {
|
|
continue // No issues file
|
|
}
|
|
|
|
// Check if the issues file is old and non-empty
|
|
if info.ModTime().Before(cutoff) && info.Size() > 0 {
|
|
orphans = append(orphans, fmt.Sprintf("%s: issues.jsonl last modified %s",
|
|
rigName, info.ModTime().Format("2006-01-02 15:04")))
|
|
}
|
|
}
|
|
|
|
if len(orphans) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d rig(s) have stale ephemeral data (>24h old)", len(orphans)),
|
|
Details: orphans,
|
|
FixHint: "Manual review required - these may contain unsquashed work",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No orphaned molecules found",
|
|
}
|
|
}
|
|
|
|
// EphemeralSizeCheck warns if ephemeral repo is too large (>100MB).
|
|
type EphemeralSizeCheck struct {
|
|
BaseCheck
|
|
}
|
|
|
|
// NewEphemeralSizeCheck creates a new ephemeral size check.
|
|
func NewEphemeralSizeCheck() *EphemeralSizeCheck {
|
|
return &EphemeralSizeCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "ephemeral-size",
|
|
CheckDescription: "Check if ephemeral beads directories are too large (>100MB)",
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks the size of ephemeral beads directories.
|
|
func (c *EphemeralSizeCheck) Run(ctx *CheckContext) *CheckResult {
|
|
rigs, err := discoverRigs(ctx.TownRoot)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: "Failed to discover rigs",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
if len(rigs) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No rigs configured",
|
|
}
|
|
}
|
|
|
|
const maxSize = 100 * 1024 * 1024 // 100MB
|
|
var oversized []string
|
|
|
|
for _, rigName := range rigs {
|
|
ephemeralPath := filepath.Join(ctx.TownRoot, rigName, ".beads-ephemeral")
|
|
if _, err := os.Stat(ephemeralPath); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
size, err := dirSize(ephemeralPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if size > maxSize {
|
|
oversized = append(oversized, fmt.Sprintf("%s: %s",
|
|
rigName, formatSize(size)))
|
|
}
|
|
}
|
|
|
|
if len(oversized) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d rig(s) have oversized ephemeral directories", len(oversized)),
|
|
Details: oversized,
|
|
FixHint: "Consider cleaning up old completed molecules",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "All ephemeral directories within size limits",
|
|
}
|
|
}
|
|
|
|
// EphemeralStaleCheck detects molecules with no activity in the last hour.
|
|
type EphemeralStaleCheck struct {
|
|
BaseCheck
|
|
}
|
|
|
|
// NewEphemeralStaleCheck creates a new ephemeral stale check.
|
|
func NewEphemeralStaleCheck() *EphemeralStaleCheck {
|
|
return &EphemeralStaleCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "ephemeral-stale",
|
|
CheckDescription: "Check for stale molecules (no activity in last hour)",
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks for stale molecules.
|
|
func (c *EphemeralStaleCheck) Run(ctx *CheckContext) *CheckResult {
|
|
rigs, err := discoverRigs(ctx.TownRoot)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: "Failed to discover rigs",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
if len(rigs) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No rigs configured",
|
|
}
|
|
}
|
|
|
|
var stale []string
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
|
|
for _, rigName := range rigs {
|
|
ephemeralPath := filepath.Join(ctx.TownRoot, rigName, ".beads-ephemeral")
|
|
if _, err := os.Stat(ephemeralPath); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
// Check for any recent activity in the ephemeral directory
|
|
// We look at the most recent modification time of any file
|
|
var mostRecent time.Time
|
|
_ = filepath.Walk(ephemeralPath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if !info.IsDir() && info.ModTime().After(mostRecent) {
|
|
mostRecent = info.ModTime()
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// If there are files and the most recent is older than 1 hour
|
|
if !mostRecent.IsZero() && mostRecent.Before(cutoff) {
|
|
stale = append(stale, fmt.Sprintf("%s: last activity %s ago",
|
|
rigName, formatDuration(time.Since(mostRecent))))
|
|
}
|
|
}
|
|
|
|
if len(stale) > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d rig(s) have stale ephemeral activity", len(stale)),
|
|
Details: stale,
|
|
FixHint: "Check if polecats are stuck or crashed",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No stale ephemeral activity detected",
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// discoverRigs finds all registered rigs (shared helper).
|
|
func discoverRigs(townRoot string) ([]string, error) {
|
|
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
data, err := os.ReadFile(rigsPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var rigsConfig config.RigsConfig
|
|
if err := json.Unmarshal(data, &rigsConfig); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rigs []string
|
|
for name := range rigsConfig.Rigs {
|
|
rigs = append(rigs, name)
|
|
}
|
|
return rigs, nil
|
|
}
|
|
|
|
// dirSize calculates the total size of a directory.
|
|
func dirSize(path string) (int64, error) {
|
|
var size int64
|
|
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
size += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
return size, err
|
|
}
|
|
|
|
// formatSize formats bytes as human-readable size.
|
|
func formatSize(bytes int64) string {
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
switch {
|
|
case bytes >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(bytes)/GB)
|
|
case bytes >= MB:
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/MB)
|
|
case bytes >= KB:
|
|
return fmt.Sprintf("%.1f KB", float64(bytes)/KB)
|
|
default:
|
|
return fmt.Sprintf("%d bytes", bytes)
|
|
}
|
|
}
|
|
|
|
// formatDuration formats a duration as human-readable string.
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%.0f seconds", d.Seconds())
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%.0f minutes", d.Minutes())
|
|
}
|
|
if d < 24*time.Hour {
|
|
return fmt.Sprintf("%.1f hours", d.Hours())
|
|
}
|
|
return fmt.Sprintf("%.1f days", d.Hours()/24)
|
|
}
|