Files
gastown/internal/townlog/logger.go
max 1b69576573 fix: Address golangci-lint errors (errcheck, gosec) (#76)
Apply PR #76 from dannomayernotabot:

- Add golangci exclusions for internal package false positives
- Tighten file permissions (0644 -> 0600) for sensitive files
- Add ReadHeaderTimeout to HTTP server (slowloris prevention)
- Explicit error ignoring with _ = for intentional cases
- Add //nolint comments with justifications
- Spelling: cancelled -> canceled (US locale)

Co-Authored-By: dannomayernotabot <noreply@github.com>

🤖 Generated with Claude Code
2026-01-03 16:11:55 -08:00

362 lines
8.8 KiB
Go

// 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"
)
// 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"
}
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
}