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>
411 lines
12 KiB
Go
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)
|
|
}
|