Files
gastown/internal/cmd/log.go
Steve Yegge 91fa5e63dc Replace 'gastown' with 'greenplace' in user-facing docs/examples
Using "greenplace" (The Green Place from Mad Max: Fury Road) as the
canonical example project/rig name in documentation and help text.
This provides a clearer distinction from the actual gastown repo name.

Changes:
- docs/*.md: Updated all example paths and commands
- internal/cmd/*.go: Updated help text examples
- internal/templates/: Updated example references
- Tests: Updated to use greenplace in example session names

Note: Import paths (github.com/steveyegge/gastown) and actual code
paths referencing the gastown repo structure are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 18:03:06 -08:00

411 lines
12 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
// Log command flags
var (
logTail int
logType string
logAgent string
logSince string
logFollow bool
// log crash flags
crashAgent string
crashSession string
crashExitCode int
)
var logCmd = &cobra.Command{
Use: "log",
GroupID: GroupDiag,
Short: "View town activity log",
Long: `View the centralized log of Gas Town agent lifecycle events.
Events logged include:
spawn - new agent created
wake - agent resumed
nudge - message injected into agent
handoff - agent handed off to fresh session
done - agent finished work
crash - agent exited unexpectedly
kill - agent killed intentionally
Examples:
gt log # Show last 20 events
gt log -n 50 # Show last 50 events
gt log --type spawn # Show only spawn events
gt log --agent greenplace/ # Show events for gastown rig
gt log --since 1h # Show events from last hour
gt log -f # Follow log (like tail -f)`,
RunE: runLog,
}
var logCrashCmd = &cobra.Command{
Use: "crash",
Short: "Record a crash event (called by tmux pane-died hook)",
Long: `Record a crash event to the town log.
This command is called automatically by tmux when a pane exits unexpectedly.
It's not typically run manually.
The exit code determines if this was a crash or expected exit:
- Exit code 0: Expected exit (logged as 'done' if no other done was recorded)
- Exit code non-zero: Crash (logged as 'crash')
Examples:
gt log crash --agent greenplace/Toast --session gt-greenplace-Toast --exit-code 1`,
RunE: runLogCrash,
}
func init() {
logCmd.Flags().IntVarP(&logTail, "tail", "n", 20, "Number of events to show")
logCmd.Flags().StringVarP(&logType, "type", "t", "", "Filter by event type (spawn,wake,nudge,handoff,done,crash,kill)")
logCmd.Flags().StringVarP(&logAgent, "agent", "a", "", "Filter by agent prefix (e.g., gastown/, greenplace/crew/max)")
logCmd.Flags().StringVar(&logSince, "since", "", "Show events since duration (e.g., 1h, 30m, 24h)")
logCmd.Flags().BoolVarP(&logFollow, "follow", "f", false, "Follow log output (like tail -f)")
// crash subcommand flags
logCrashCmd.Flags().StringVar(&crashAgent, "agent", "", "Agent ID (e.g., greenplace/Toast)")
logCrashCmd.Flags().StringVar(&crashSession, "session", "", "Tmux session name")
logCrashCmd.Flags().IntVar(&crashExitCode, "exit-code", -1, "Exit code from pane")
_ = logCrashCmd.MarkFlagRequired("agent")
logCmd.AddCommand(logCrashCmd)
rootCmd.AddCommand(logCmd)
}
func runLog(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
logPath := fmt.Sprintf("%s/logs/town.log", townRoot)
// If following, use tail -f
if logFollow {
return followLog(logPath)
}
// Check if log file exists
if _, err := os.Stat(logPath); os.IsNotExist(err) {
fmt.Printf("%s No log file yet (no events recorded)\n", style.Dim.Render("○"))
return nil
}
// Read events
events, err := townlog.ReadEvents(townRoot)
if err != nil {
return fmt.Errorf("reading events: %w", err)
}
if len(events) == 0 {
fmt.Printf("%s No events in log\n", style.Dim.Render("○"))
return nil
}
// Build filter
filter := townlog.Filter{}
if logType != "" {
filter.Type = townlog.EventType(logType)
}
if logAgent != "" {
filter.Agent = logAgent
}
if logSince != "" {
duration, err := time.ParseDuration(logSince)
if err != nil {
return fmt.Errorf("invalid --since duration: %w", err)
}
filter.Since = time.Now().Add(-duration)
}
// Apply filter
events = townlog.FilterEvents(events, filter)
// Apply tail limit
if logTail > 0 && len(events) > logTail {
events = events[len(events)-logTail:]
}
if len(events) == 0 {
fmt.Printf("%s No events match filter\n", style.Dim.Render("○"))
return nil
}
// Print events
for _, e := range events {
printEvent(e)
}
return nil
}
// followLog uses tail -f to follow the log file.
func followLog(logPath string) error {
// Check if log file exists, create empty if not
if _, err := os.Stat(logPath); os.IsNotExist(err) {
// Create logs directory and empty file
if err := os.MkdirAll(fmt.Sprintf("%s", logPath[:len(logPath)-len("town.log")-1]), 0755); err != nil {
return fmt.Errorf("creating logs directory: %w", err)
}
if _, err := os.Create(logPath); err != nil {
return fmt.Errorf("creating log file: %w", err)
}
}
fmt.Printf("%s Following %s (Ctrl+C to stop)\n\n", style.Dim.Render("○"), logPath)
tailCmd := exec.Command("tail", "-f", logPath)
tailCmd.Stdout = os.Stdout
tailCmd.Stderr = os.Stderr
return tailCmd.Run()
}
// printEvent prints a single event with styling.
func printEvent(e townlog.Event) {
ts := e.Timestamp.Format("2006-01-02 15:04:05")
// Color-code event types
var typeStr string
switch e.Type {
case townlog.EventSpawn:
typeStr = style.Success.Render("[spawn]")
case townlog.EventWake:
typeStr = style.Bold.Render("[wake]")
case townlog.EventNudge:
typeStr = style.Dim.Render("[nudge]")
case townlog.EventHandoff:
typeStr = style.Bold.Render("[handoff]")
case townlog.EventDone:
typeStr = style.Success.Render("[done]")
case townlog.EventCrash:
typeStr = style.Error.Render("[crash]")
case townlog.EventKill:
typeStr = style.Warning.Render("[kill]")
case townlog.EventCallback:
typeStr = style.Bold.Render("[callback]")
case townlog.EventPatrolStarted:
typeStr = style.Bold.Render("[patrol_started]")
case townlog.EventPolecatChecked:
typeStr = style.Dim.Render("[polecat_checked]")
case townlog.EventPolecatNudged:
typeStr = style.Warning.Render("[polecat_nudged]")
case townlog.EventEscalationSent:
typeStr = style.Error.Render("[escalation_sent]")
case townlog.EventPatrolComplete:
typeStr = style.Success.Render("[patrol_complete]")
default:
typeStr = fmt.Sprintf("[%s]", e.Type)
}
detail := formatEventDetail(e)
fmt.Printf("%s %s %s %s\n", style.Dim.Render(ts), typeStr, e.Agent, detail)
}
// formatEventDetail returns a human-readable detail string for an event.
func formatEventDetail(e townlog.Event) string {
switch e.Type {
case townlog.EventSpawn:
if e.Context != "" {
return fmt.Sprintf("spawned for %s", e.Context)
}
return "spawned"
case townlog.EventWake:
if e.Context != "" {
return fmt.Sprintf("resumed (%s)", e.Context)
}
return "resumed"
case townlog.EventNudge:
if e.Context != "" {
return fmt.Sprintf("nudged with %q", truncateStr(e.Context, 40))
}
return "nudged"
case townlog.EventHandoff:
if e.Context != "" {
return fmt.Sprintf("handed off (%s)", e.Context)
}
return "handed off"
case townlog.EventDone:
if e.Context != "" {
return fmt.Sprintf("completed %s", e.Context)
}
return "completed work"
case townlog.EventCrash:
if e.Context != "" {
return fmt.Sprintf("exited unexpectedly (%s)", e.Context)
}
return "exited unexpectedly"
case townlog.EventKill:
if e.Context != "" {
return fmt.Sprintf("killed (%s)", e.Context)
}
return "killed"
case townlog.EventCallback:
if e.Context != "" {
return fmt.Sprintf("callback: %s", e.Context)
}
return "callback processed"
case townlog.EventPatrolStarted:
if e.Context != "" {
return fmt.Sprintf("started patrol (%s)", e.Context)
}
return "started patrol"
case townlog.EventPolecatChecked:
if e.Context != "" {
return fmt.Sprintf("checked %s", e.Context)
}
return "checked polecat"
case townlog.EventPolecatNudged:
if e.Context != "" {
return fmt.Sprintf("nudged (%s)", e.Context)
}
return "nudged polecat"
case townlog.EventEscalationSent:
if e.Context != "" {
return fmt.Sprintf("escalated (%s)", e.Context)
}
return "escalated"
case townlog.EventPatrolComplete:
if e.Context != "" {
return fmt.Sprintf("patrol complete (%s)", e.Context)
}
return "patrol complete"
default:
if e.Context != "" {
return fmt.Sprintf("%s (%s)", e.Type, e.Context)
}
return string(e.Type)
}
}
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
// runLogCrash handles the "gt log crash" command from tmux pane-died hooks.
func runLogCrash(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil || townRoot == "" {
// Try to find town root from conventional location
// This is called from tmux hook which may not have proper cwd
home := os.Getenv("HOME")
defaultRoot := home + "/gt"
if _, statErr := os.Stat(defaultRoot + "/mayor"); statErr == nil {
townRoot = defaultRoot
}
if townRoot == "" {
return fmt.Errorf("cannot find town root (tried cwd and ~/gt)")
}
}
// Determine event type based on exit code
var eventType townlog.EventType
var context string
if crashExitCode == 0 {
// Exit code 0 = normal exit
// Could be handoff, done, or user quit - we log as "done" if no prior done event
// The Witness can analyze further if needed
eventType = townlog.EventDone
context = "exited normally"
} else if crashExitCode == 130 {
// Exit code 130 = Ctrl+C (SIGINT)
// This is typically intentional user interrupt
eventType = townlog.EventKill
context = fmt.Sprintf("interrupted (exit %d)", crashExitCode)
} else {
// Non-zero exit = crash
eventType = townlog.EventCrash
context = fmt.Sprintf("exit code %d", crashExitCode)
if crashSession != "" {
context += fmt.Sprintf(" (session: %s)", crashSession)
}
}
// Log the event
logger := townlog.NewLogger(townRoot)
if err := logger.Log(eventType, crashAgent, context); err != nil {
return fmt.Errorf("logging event: %w", err)
}
return nil
}
// LogEvent is a helper that logs an event from anywhere in the codebase.
// It finds the town root and logs the event.
func LogEvent(eventType townlog.EventType, agent, context string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil {
return err // Silently fail if not in a workspace
}
if townRoot == "" {
return nil
}
logger := townlog.NewLogger(townRoot)
return logger.Log(eventType, agent, context)
}
// LogEventWithRoot logs an event when the town root is already known.
func LogEventWithRoot(townRoot string, eventType townlog.EventType, agent, context string) error {
logger := townlog.NewLogger(townRoot)
return logger.Log(eventType, agent, context)
}
// Convenience functions for common events
// LogSpawn logs a spawn event.
func LogSpawn(townRoot, agent, issueID string) error {
return LogEventWithRoot(townRoot, townlog.EventSpawn, agent, issueID)
}
// LogWake logs a wake event.
func LogWake(townRoot, agent, context string) error {
return LogEventWithRoot(townRoot, townlog.EventWake, agent, context)
}
// LogNudge logs a nudge event.
func LogNudge(townRoot, agent, message string) error {
return LogEventWithRoot(townRoot, townlog.EventNudge, agent, strings.TrimSpace(message))
}
// LogHandoff logs a handoff event.
func LogHandoff(townRoot, agent, context string) error {
return LogEventWithRoot(townRoot, townlog.EventHandoff, agent, context)
}
// LogDone logs a done event.
func LogDone(townRoot, agent, issueID string) error {
return LogEventWithRoot(townRoot, townlog.EventDone, agent, issueID)
}
// LogCrash logs a crash event.
func LogCrash(townRoot, agent, reason string) error {
return LogEventWithRoot(townRoot, townlog.EventCrash, agent, reason)
}
// LogKill logs a kill event.
func LogKill(townRoot, agent, reason string) error {
return LogEventWithRoot(townRoot, townlog.EventKill, agent, reason)
}