Files
gastown/internal/doctor/wisp_check.go
Steve Yegge 5d7291962a Rename ephemeral -> wisp terminology throughout Gas Town
- .beads-ephemeral/ -> .beads-wisp/
- Rename doctor checks: EphemeralCheck -> WispCheck
- Update all docs to use 'transient' for polecats, 'wisp' for molecules
- Preserve 'ephemeral' only as descriptive adjective for wisps
- Steam engine metaphor: wisps are steam vapors that dissipate

Part of Christmas launch wisp terminology unification.
2025-12-22 00:55:31 -08:00

520 lines
12 KiB
Go

package doctor
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/config"
)
// WispExistsCheck verifies that .beads-wisp/ exists for each rig.
type WispExistsCheck struct {
FixableCheck
missingRigs []string // Cached for fix
}
// NewWispExistsCheck creates a new wisp exists check.
func NewWispExistsCheck() *WispExistsCheck {
return &WispExistsCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "wisp-exists",
CheckDescription: "Check if wisp directory exists for each rig",
},
},
}
}
// Run checks if .beads-wisp/ exists for each rig.
func (c *WispExistsCheck) 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 {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
if _, err := os.Stat(wispPath); 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 wisp 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 wisp directory", len(rigs)),
}
}
// Fix creates missing .beads-wisp/ directories.
func (c *WispExistsCheck) Fix(ctx *CheckContext) error {
for _, rigName := range c.missingRigs {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
if err := os.MkdirAll(wispPath, 0755); err != nil {
return fmt.Errorf("creating %s: %w", wispPath, err)
}
}
return nil
}
// discoverRigs finds all registered rigs.
func (c *WispExistsCheck) 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
}
// WispGitCheck verifies that .beads-wisp/ is a valid git repo.
type WispGitCheck struct {
FixableCheck
invalidRigs []string // Cached for fix
}
// NewWispGitCheck creates a new wisp git check.
func NewWispGitCheck() *WispGitCheck {
return &WispGitCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "wisp-git",
CheckDescription: "Check if wisp directories are valid git repos",
},
},
}
}
// Run checks if .beads-wisp/ directories are valid git repos.
func (c *WispGitCheck) 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 a wisp dir
var invalid []string
var checked int
for _, rigName := range rigs {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
continue // Skip if directory doesn't exist (handled by wisp-exists)
}
checked++
// Check if it's a valid git repo
gitDir := filepath.Join(wispPath, ".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 wisp directories to check",
}
}
if len(invalid) > 0 {
c.invalidRigs = invalid
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d wisp 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 wisp directories are valid git repos", checked),
}
}
// Fix initializes git repos in wisp directories.
func (c *WispGitCheck) Fix(ctx *CheckContext) error {
for _, rigName := range c.invalidRigs {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
cmd := exec.Command("git", "init")
cmd.Dir = wispPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("initializing git in %s: %w", wispPath, err)
}
// Create config.yaml for wisp storage
configPath := filepath.Join(wispPath, "config.yaml")
configContent := "wisp: true\n# No sync-branch - wisps are local only\n"
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("creating config.yaml in %s: %w", wispPath, err)
}
}
return nil
}
// WispOrphansCheck detects molecules started but never squashed (>24h old).
type WispOrphansCheck struct {
BaseCheck
}
// NewWispOrphansCheck creates a new wisp orphans check.
func NewWispOrphansCheck() *WispOrphansCheck {
return &WispOrphansCheck{
BaseCheck: BaseCheck{
CheckName: "wisp-orphans",
CheckDescription: "Check for orphaned wisps (>24h old, never squashed)",
},
}
}
// Run checks for orphaned wisps.
func (c *WispOrphansCheck) 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 {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
continue
}
// Look for molecule directories or issue files older than 24h
issuesPath := filepath.Join(wispPath, "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 wisp 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 wisps found",
}
}
// WispSizeCheck warns if wisp repo is too large (>100MB).
type WispSizeCheck struct {
BaseCheck
}
// NewWispSizeCheck creates a new wisp size check.
func NewWispSizeCheck() *WispSizeCheck {
return &WispSizeCheck{
BaseCheck: BaseCheck{
CheckName: "wisp-size",
CheckDescription: "Check if wisp directories are too large (>100MB)",
},
}
}
// Run checks the size of wisp directories.
func (c *WispSizeCheck) 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 {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
continue
}
size, err := dirSize(wispPath)
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 wisp directories", len(oversized)),
Details: oversized,
FixHint: "Consider cleaning up old completed molecules",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "All wisp directories within size limits",
}
}
// WispStaleCheck detects molecules with no activity in the last hour.
type WispStaleCheck struct {
BaseCheck
}
// NewWispStaleCheck creates a new wisp stale check.
func NewWispStaleCheck() *WispStaleCheck {
return &WispStaleCheck{
BaseCheck: BaseCheck{
CheckName: "wisp-stale",
CheckDescription: "Check for stale wisps (no activity in last hour)",
},
}
}
// Run checks for stale wisps.
func (c *WispStaleCheck) 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 {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
continue
}
// Check for any recent activity in the wisp directory
// We look at the most recent modification time of any file
var mostRecent time.Time
_ = filepath.Walk(wispPath, 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 wisp activity", len(stale)),
Details: stale,
FixHint: "Check if polecats are stuck or crashed",
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No stale wisp 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)
}