// Package townlog provides centralized logging for Gas Town agent lifecycle events. package townlog import ( "fmt" "os" "path/filepath" "sync" "time" ) // EventType represents the type of agent lifecycle event. type EventType string const ( // EventSpawn indicates a new agent was created. EventSpawn EventType = "spawn" // EventWake indicates an agent was resumed. EventWake EventType = "wake" // EventNudge indicates a message was injected into an agent. EventNudge EventType = "nudge" // EventHandoff indicates an agent handed off to a fresh session. EventHandoff EventType = "handoff" // EventDone indicates an agent finished its work. EventDone EventType = "done" // EventCrash indicates an agent exited unexpectedly. EventCrash EventType = "crash" // EventKill indicates an agent was killed intentionally. EventKill EventType = "kill" // EventCallback indicates a callback was processed during patrol. EventCallback EventType = "callback" // Witness patrol events EventPatrolStarted EventType = "patrol_started" EventPolecatChecked EventType = "polecat_checked" EventPolecatNudged EventType = "polecat_nudged" EventEscalationSent EventType = "escalation_sent" EventPatrolComplete EventType = "patrol_complete" // Session death events (for crash investigation) EventSessionDeath EventType = "session_death" // Session terminated (with reason) EventMassDeath EventType = "mass_death" // Multiple sessions died in short window ) // Event represents a single agent lifecycle event. type Event struct { Timestamp time.Time `json:"timestamp"` Type EventType `json:"type"` Agent string `json:"agent"` // e.g., "gastown/crew/max" or "gastown/polecats/Toast" Context string `json:"context,omitempty"` // Additional context (issue ID, error message, etc.) } // Logger handles writing events to the town log file. type Logger struct { logPath string mu sync.Mutex } // logDir returns the directory for town logs. func logDir(townRoot string) string { return filepath.Join(townRoot, "logs") } // logPath returns the path to the town log file. func logPath(townRoot string) string { return filepath.Join(logDir(townRoot), "town.log") } // NewLogger creates a new Logger for the given town root. func NewLogger(townRoot string) *Logger { return &Logger{ logPath: logPath(townRoot), } } // LogEvent logs a single event to the town log. func (l *Logger) LogEvent(event Event) error { l.mu.Lock() defer l.mu.Unlock() // Ensure log directory exists if err := os.MkdirAll(filepath.Dir(l.logPath), 0755); err != nil { return fmt.Errorf("creating log directory: %w", err) } // Open file for appending f, err := os.OpenFile(l.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return fmt.Errorf("opening log file: %w", err) } defer f.Close() // Write human-readable log line line := formatLogLine(event) if _, err := f.WriteString(line + "\n"); err != nil { return fmt.Errorf("writing log line: %w", err) } return nil } // Log is a convenience method that creates an Event and logs it. func (l *Logger) Log(eventType EventType, agent, context string) error { return l.LogEvent(Event{ Timestamp: time.Now(), Type: eventType, Agent: agent, Context: context, }) } // formatLogLine formats an event as a human-readable log line. // Format: 2025-12-26 15:30:45 [spawn] gastown/crew/max spawned for gt-xyz func formatLogLine(e Event) string { ts := e.Timestamp.Format("2006-01-02 15:04:05") var detail string switch e.Type { case EventSpawn: if e.Context != "" { detail = fmt.Sprintf("spawned for %s", e.Context) } else { detail = "spawned" } case EventWake: detail = "resumed" if e.Context != "" { detail += fmt.Sprintf(" (%s)", e.Context) } case EventNudge: if e.Context != "" { detail = fmt.Sprintf("nudged with %q", truncate(e.Context, 50)) } else { detail = "nudged" } case EventHandoff: detail = "handed off" if e.Context != "" { detail += fmt.Sprintf(" (%s)", e.Context) } case EventDone: if e.Context != "" { detail = fmt.Sprintf("completed %s", e.Context) } else { detail = "completed work" } case EventCrash: if e.Context != "" { detail = fmt.Sprintf("exited unexpectedly (%s)", e.Context) } else { detail = "exited unexpectedly" } case EventKill: if e.Context != "" { detail = fmt.Sprintf("killed (%s)", e.Context) } else { detail = "killed" } case EventCallback: if e.Context != "" { detail = fmt.Sprintf("callback: %s", e.Context) } else { detail = "callback processed" } case EventPatrolStarted: if e.Context != "" { detail = fmt.Sprintf("started patrol (%s)", e.Context) } else { detail = "started patrol" } case EventPolecatChecked: if e.Context != "" { detail = fmt.Sprintf("checked polecat %s", e.Context) } else { detail = "checked polecat" } case EventPolecatNudged: if e.Context != "" { detail = fmt.Sprintf("nudged polecat (%s)", e.Context) } else { detail = "nudged polecat" } case EventEscalationSent: if e.Context != "" { detail = fmt.Sprintf("escalated (%s)", e.Context) } else { detail = "escalated" } case EventPatrolComplete: if e.Context != "" { detail = fmt.Sprintf("patrol complete (%s)", e.Context) } else { detail = "patrol complete" } case EventSessionDeath: if e.Context != "" { detail = fmt.Sprintf("session terminated (%s)", e.Context) } else { detail = "session terminated" } case EventMassDeath: if e.Context != "" { detail = fmt.Sprintf("MASS SESSION DEATH (%s)", e.Context) } else { detail = "MASS SESSION DEATH" } default: detail = string(e.Type) if e.Context != "" { detail += fmt.Sprintf(" (%s)", e.Context) } } return fmt.Sprintf("%s [%s] %s %s", ts, e.Type, e.Agent, detail) } // truncate shortens a string to max length with ellipsis. func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } // ReadEvents reads all events from the log file. // Useful for filtering and analysis. func ReadEvents(townRoot string) ([]Event, error) { path := logPath(townRoot) content, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed from trusted townRoot if err != nil { if os.IsNotExist(err) { return nil, nil // No log file yet } return nil, fmt.Errorf("reading log file: %w", err) } return ParseLogLines(string(content)) } // ParseLogLines parses log lines back into Events. // This is the inverse of formatLogLine for filtering. func ParseLogLines(content string) ([]Event, error) { var events []Event lines := splitLines(content) for _, line := range lines { if line == "" { continue } event, err := parseLogLine(line) if err != nil { continue // Skip malformed lines } events = append(events, event) } return events, nil } // parseLogLine parses a single log line into an Event. // Format: 2025-12-26 15:30:45 [spawn] gastown/crew/max spawned for gt-xyz func parseLogLine(line string) (Event, error) { var event Event // Parse timestamp (first 19 chars: "2006-01-02 15:04:05") if len(line) < 19 { return event, fmt.Errorf("line too short") } ts, err := time.Parse("2006-01-02 15:04:05", line[:19]) if err != nil { return event, fmt.Errorf("parsing timestamp: %w", err) } event.Timestamp = ts // Find event type in brackets rest := line[20:] // Skip timestamp and space if len(rest) < 3 || rest[0] != '[' { return event, fmt.Errorf("missing event type") } closeBracket := -1 for i, c := range rest { if c == ']' { closeBracket = i break } } if closeBracket < 0 { return event, fmt.Errorf("unclosed bracket") } event.Type = EventType(rest[1:closeBracket]) // Rest is " agent details" rest = rest[closeBracket+1:] if len(rest) < 2 || rest[0] != ' ' { return event, fmt.Errorf("missing agent") } rest = rest[1:] // Find first space after agent spaceIdx := -1 for i, c := range rest { if c == ' ' { spaceIdx = i break } } if spaceIdx < 0 { event.Agent = rest } else { event.Agent = rest[:spaceIdx] // The rest is context info (not worth parsing further) } return event, nil } func splitLines(s string) []string { var lines []string start := 0 for i := 0; i < len(s); i++ { if s[i] == '\n' { lines = append(lines, s[start:i]) start = i + 1 } } if start < len(s) { lines = append(lines, s[start:]) } return lines } // TailEvents returns the last n events from the log. func TailEvents(townRoot string, n int) ([]Event, error) { events, err := ReadEvents(townRoot) if err != nil { return nil, err } if len(events) <= n { return events, nil } return events[len(events)-n:], nil } // FilterEvents returns events matching the filter criteria. type Filter struct { Type EventType // Filter by event type (empty for all) Agent string // Filter by agent prefix (empty for all) Since time.Time // Filter by time (zero for all) } // FilterEvents applies a filter to events. func FilterEvents(events []Event, f Filter) []Event { var result []Event for _, e := range events { if f.Type != "" && e.Type != f.Type { continue } if f.Agent != "" && !hasPrefix(e.Agent, f.Agent) { continue } if !f.Since.IsZero() && e.Timestamp.Before(f.Since) { continue } result = append(result, e) } return result } func hasPrefix(s, prefix string) bool { if len(s) < len(prefix) { return false } return s[:len(prefix)] == prefix }