Implement town activity logging (gt-ewzon)

Add centralized logging for Gas Town agent lifecycle events:
- spawn: new agent created
- wake: agent resumed
- nudge: message injected
- handoff: agent handed off
- done: agent finished work
- crash: agent exited unexpectedly
- kill: agent killed intentionally

Implementation:
- Add internal/townlog package with LogEvent() function
- Log to ~/gt/logs/town.log with human-readable format
- Add gt log command to view/tail/filter logs
- Integrate logging into spawn, nudge, handoff, done, stop, session commands

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-26 15:51:28 -08:00
parent d524f65af3
commit e2b8f16c48
9 changed files with 903 additions and 0 deletions

View File

@@ -220,5 +220,8 @@ func runDone(cmd *cobra.Command, args []string) error {
fmt.Printf("%s Witness notified of %s\n", style.Bold.Render("✓"), exitType)
}
// Log done event
LogDone(townRoot, sender, issueID)
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/wisp"
"github.com/steveyegge/gastown/internal/workspace"
)
var handoffCmd = &cobra.Command{
@@ -126,6 +127,15 @@ func runHandoff(cmd *cobra.Command, args []string) error {
// Handing off ourselves - print feedback then respawn
fmt.Printf("%s Handing off %s...\n", style.Bold.Render("🤝"), currentSession)
// Log handoff event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := sessionToGTRole(currentSession)
if agent == "" {
agent = currentSession
}
LogHandoff(townRoot, agent, handoffSubject)
}
// Dry run mode - show what would happen (BEFORE any side effects)
if handoffDryRun {
if handoffSubject != "" || handoffMessage != "" {

290
internal/cmd/log.go Normal file
View File

@@ -0,0 +1,290 @@
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
)
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 gastown/ # Show events for gastown rig
gt log --since 1h # Show events from last hour
gt log -f # Follow log (like tail -f)`,
RunE: runLog,
}
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/, gastown/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)")
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]")
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"
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] + "..."
}
// 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)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
func init() {
@@ -60,6 +61,11 @@ func runNudge(cmd *cobra.Command, args []string) error {
}
fmt.Printf("%s Nudged %s/%s\n", style.Bold.Render("✓"), rigName, polecatName)
// Log nudge event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
LogNudge(townRoot, target, message)
}
} else {
// Raw session name (legacy)
exists, err := t.HasSession(target)
@@ -75,6 +81,11 @@ func runNudge(cmd *cobra.Command, args []string) error {
}
fmt.Printf("✓ Nudged %s\n", target)
// Log nudge event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
LogNudge(townRoot, target, message)
}
}
return nil

View File

@@ -17,6 +17,7 @@ import (
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -240,6 +241,13 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"),
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
// Log wake event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := fmt.Sprintf("%s/%s", rigName, polecatName)
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventWake, agent, sessionIssue)
}
return nil
}
@@ -264,6 +272,18 @@ func runSessionStop(cmd *cobra.Command, args []string) error {
}
fmt.Printf("%s Session stopped.\n", style.Bold.Render("✓"))
// Log kill event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := fmt.Sprintf("%s/%s", rigName, polecatName)
reason := "gt session stop"
if sessionForce {
reason = "gt session stop --force"
}
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventKill, agent, reason)
}
return nil
}

View File

@@ -461,6 +461,9 @@ func runSpawn(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"),
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
// Log spawn event
LogSpawn(townRoot, polecatAddress, assignmentID)
// NOTE: We do NOT send a nudge here. Claude Code takes 10-20+ seconds to initialize,
// and sending keys before the prompt is ready causes them to be mangled.
// The Deacon will poll with WaitForClaudeReady and send a trigger when ready.

View File

@@ -11,6 +11,7 @@ import (
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -141,6 +142,11 @@ func runStop(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"),
r.Name, info.Polecat)
// Log kill event
agent := fmt.Sprintf("%s/%s", r.Name, info.Polecat)
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventKill, agent, "gt stop")
// Log captured output (truncated)
if len(output) > 200 {
output = output[len(output)-200:]

323
internal/townlog/townlog.go Normal file
View File

@@ -0,0 +1,323 @@
// Package townlog provides centralized logging for Gas Town agent lifecycle events.
package townlog
import (
"encoding/json"
"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"
)
// 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, 0644)
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"
}
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)
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
}
// LogEventJSON writes an event in JSON format for machine parsing.
// Returns the JSON representation.
func (e Event) JSON() ([]byte, error) {
return json.Marshal(e)
}
// 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
}

View File

@@ -0,0 +1,237 @@
package townlog
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestFormatLogLine(t *testing.T) {
ts := time.Date(2025, 12, 26, 15, 30, 45, 0, time.UTC)
tests := []struct {
name string
event Event
contains []string
}{
{
name: "spawn event",
event: Event{
Timestamp: ts,
Type: EventSpawn,
Agent: "gastown/crew/max",
Context: "gt-xyz",
},
contains: []string{"2025-12-26 15:30:45", "[spawn]", "gastown/crew/max", "spawned for gt-xyz"},
},
{
name: "nudge event",
event: Event{
Timestamp: ts,
Type: EventNudge,
Agent: "gastown/crew/max",
Context: "start work",
},
contains: []string{"[nudge]", "gastown/crew/max", "nudged with"},
},
{
name: "done event",
event: Event{
Timestamp: ts,
Type: EventDone,
Agent: "gastown/crew/max",
Context: "gt-abc",
},
contains: []string{"[done]", "completed gt-abc"},
},
{
name: "crash event",
event: Event{
Timestamp: ts,
Type: EventCrash,
Agent: "gastown/polecats/Toast",
Context: "signal 9",
},
contains: []string{"[crash]", "exited unexpectedly", "signal 9"},
},
{
name: "kill event",
event: Event{
Timestamp: ts,
Type: EventKill,
Agent: "gastown/polecats/Toast",
Context: "gt stop",
},
contains: []string{"[kill]", "killed", "gt stop"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
line := formatLogLine(tt.event)
for _, want := range tt.contains {
if !strings.Contains(line, want) {
t.Errorf("formatLogLine() = %q, want it to contain %q", line, want)
}
}
})
}
}
func TestParseLogLine(t *testing.T) {
tests := []struct {
name string
line string
wantErr bool
check func(Event) bool
}{
{
name: "valid spawn line",
line: "2025-12-26 15:30:45 [spawn] gastown/crew/max spawned for gt-xyz",
check: func(e Event) bool {
return e.Type == EventSpawn && e.Agent == "gastown/crew/max"
},
},
{
name: "valid nudge line",
line: "2025-12-26 15:31:02 [nudge] gastown/crew/max nudged with \"start\"",
check: func(e Event) bool {
return e.Type == EventNudge && e.Agent == "gastown/crew/max"
},
},
{
name: "too short",
line: "short",
wantErr: true,
},
{
name: "missing bracket",
line: "2025-12-26 15:30:45 spawn gastown/crew/max",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event, err := parseLogLine(tt.line)
if tt.wantErr {
if err == nil {
t.Errorf("parseLogLine() expected error, got nil")
}
return
}
if err != nil {
t.Errorf("parseLogLine() unexpected error: %v", err)
return
}
if tt.check != nil && !tt.check(event) {
t.Errorf("parseLogLine() check failed for event: %+v", event)
}
})
}
}
func TestLoggerLogEvent(t *testing.T) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "townlog-test")
if err != nil {
t.Fatalf("creating temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
logger := NewLogger(tmpDir)
// Log an event
err = logger.Log(EventSpawn, "gastown/crew/max", "gt-xyz")
if err != nil {
t.Fatalf("Log() error: %v", err)
}
// Verify log file was created
logPath := filepath.Join(tmpDir, "logs", "town.log")
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("reading log file: %v", err)
}
if !strings.Contains(string(content), "[spawn]") {
t.Errorf("log file should contain [spawn], got: %s", content)
}
if !strings.Contains(string(content), "gastown/crew/max") {
t.Errorf("log file should contain agent name, got: %s", content)
}
}
func TestFilterEvents(t *testing.T) {
now := time.Now()
events := []Event{
{Timestamp: now.Add(-2 * time.Hour), Type: EventSpawn, Agent: "gastown/crew/max", Context: "gt-1"},
{Timestamp: now.Add(-1 * time.Hour), Type: EventNudge, Agent: "gastown/crew/max", Context: "hi"},
{Timestamp: now.Add(-30 * time.Minute), Type: EventDone, Agent: "gastown/polecats/Toast", Context: "gt-2"},
{Timestamp: now.Add(-10 * time.Minute), Type: EventSpawn, Agent: "wyvern/crew/joe", Context: "gt-3"},
}
tests := []struct {
name string
filter Filter
wantCount int
}{
{
name: "no filter",
filter: Filter{},
wantCount: 4,
},
{
name: "filter by type",
filter: Filter{Type: EventSpawn},
wantCount: 2,
},
{
name: "filter by agent prefix",
filter: Filter{Agent: "gastown/"},
wantCount: 3,
},
{
name: "filter by time",
filter: Filter{Since: now.Add(-45 * time.Minute)},
wantCount: 2,
},
{
name: "combined filters",
filter: Filter{Type: EventSpawn, Agent: "gastown/"},
wantCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FilterEvents(events, tt.filter)
if len(result) != tt.wantCount {
t.Errorf("FilterEvents() got %d events, want %d", len(result), tt.wantCount)
}
})
}
}
func TestTruncate(t *testing.T) {
tests := []struct {
input string
maxLen int
want string
}{
{"short", 10, "short"},
{"exactly10c", 10, "exactly10c"},
{"this is a longer string", 10, "this is..."},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := truncate(tt.input, tt.maxLen)
if got != tt.want {
t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
}
})
}
}