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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
290
internal/cmd/log.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
323
internal/townlog/townlog.go
Normal 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
|
||||
}
|
||||
237
internal/townlog/townlog_test.go
Normal file
237
internal/townlog/townlog_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user