diff --git a/shell/logger/cleanup.go b/shell/logger/cleanup.go new file mode 100644 index 0000000..4c6f77c --- /dev/null +++ b/shell/logger/cleanup.go @@ -0,0 +1,84 @@ +package logger + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" +) + +// runCleanup periodically removes log files older than maxAgeDays for the +// given agent. It runs until ctx is cancelled. +func runCleanup(ctx context.Context, baseDir, agentID string, maxAgeDays int, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Run once immediately at startup. + cleanOldLogs(baseDir, agentID, maxAgeDays) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cleanOldLogs(baseDir, agentID, maxAgeDays) + } + } +} + +// cleanOldLogs removes .jsonl and .jsonl.gz files older than maxAgeDays. +func cleanOldLogs(baseDir, agentID string, maxAgeDays int) { + dir := filepath.Join(baseDir, agentID) + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + cutoff := time.Now().UTC().AddDate(0, 0, -maxAgeDays) + + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !isLogFile(name) { + continue + } + + date := parseDateFromFilename(name) + if date.IsZero() { + continue + } + if date.Before(cutoff) { + os.Remove(filepath.Join(dir, name)) + } + } +} + +// isLogFile returns true for .jsonl and .jsonl.gz files. +func isLogFile(name string) bool { + return strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, ".jsonl.gz") +} + +// parseDateFromFilename extracts YYYY-MM-DD from filenames like: +// +// 2026-03-06.jsonl +// 2026-03-06.1.jsonl +// 2026-03-06.jsonl.gz +func parseDateFromFilename(name string) time.Time { + // Strip extensions. + base := strings.TrimSuffix(name, ".gz") + base = strings.TrimSuffix(base, ".jsonl") + + // Remove numeric suffix (e.g., ".1" from "2026-03-06.1"). + if idx := strings.LastIndex(base, "."); idx >= 0 { + candidate := base[:idx] + if t, err := time.Parse("2006-01-02", candidate); err == nil { + return t + } + } + + t, _ := time.Parse("2006-01-02", base) + return t +} diff --git a/shell/logger/cleanup_test.go b/shell/logger/cleanup_test.go new file mode 100644 index 0000000..aec27f1 --- /dev/null +++ b/shell/logger/cleanup_test.go @@ -0,0 +1,110 @@ +package logger + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestCleanOldLogs(t *testing.T) { + dir := t.TempDir() + agentDir := filepath.Join(dir, "bot1") + os.MkdirAll(agentDir, 0o755) + + // Create files: 10 days ago, 5 days ago, today. + files := []string{ + "2026-02-24.jsonl", + "2026-02-24.jsonl.gz", + "2026-03-01.jsonl", + "2026-03-06.jsonl", + } + for _, f := range files { + os.WriteFile(filepath.Join(agentDir, f), []byte("{}"), 0o644) + } + + // Retain 7 days → should remove 2026-02-24 files. + cleanOldLogs(dir, "bot1", 7) + + remaining, _ := os.ReadDir(agentDir) + names := make(map[string]bool) + for _, e := range remaining { + names[e.Name()] = true + } + + if names["2026-02-24.jsonl"] { + t.Error("2026-02-24.jsonl should have been removed") + } + if names["2026-02-24.jsonl.gz"] { + t.Error("2026-02-24.jsonl.gz should have been removed") + } + if !names["2026-03-01.jsonl"] { + t.Error("2026-03-01.jsonl should still exist") + } + if !names["2026-03-06.jsonl"] { + t.Error("2026-03-06.jsonl should still exist") + } +} + +func TestParseDateFromFilename(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"2026-03-06.jsonl", "2026-03-06"}, + {"2026-03-06.1.jsonl", "2026-03-06"}, + {"2026-03-06.jsonl.gz", "2026-03-06"}, + {"2026-03-06.2.jsonl.gz", "2026-03-06"}, + {"invalid.jsonl", ""}, + } + for _, tt := range tests { + d := parseDateFromFilename(tt.name) + got := "" + if !d.IsZero() { + got = d.Format("2006-01-02") + } + if got != tt.want { + t.Errorf("parseDateFromFilename(%q) = %q, want %q", tt.name, got, tt.want) + } + } +} + +func TestCleanOldLogs_EmptyDir(t *testing.T) { + dir := t.TempDir() + // Should not panic on non-existent agent dir. + cleanOldLogs(dir, "nonexistent", 7) +} + +func TestIsLogFile(t *testing.T) { + if !isLogFile("2026-03-06.jsonl") { + t.Error("should match .jsonl") + } + if !isLogFile("2026-03-06.jsonl.gz") { + t.Error("should match .jsonl.gz") + } + if isLogFile("readme.txt") { + t.Error("should not match .txt") + } +} + +func TestRunCleanup_Cancellation(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "bot1"), 0o755) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + runCleanup(ctx, dir, "bot1", 7, 50*time.Millisecond) + close(done) + }() + + time.Sleep(150 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("cleanup goroutine did not exit after cancel") + } +} diff --git a/shell/logger/logger.go b/shell/logger/logger.go new file mode 100644 index 0000000..16df413 --- /dev/null +++ b/shell/logger/logger.go @@ -0,0 +1,101 @@ +// Package logger provides structured JSONL logging for agents with daily +// file rotation, size-based splitting, automatic cleanup, and query helpers. +package logger + +import ( + "context" + "log/slog" + "os" + "time" +) + +// Standard field names for structured logging across all agents. +const ( + FieldAgentID = "agent_id" + FieldTraceID = "trace_id" + FieldAction = "action" + FieldReason = "reason" + FieldDurationMS = "duration_ms" + FieldTokensUsed = "tokens_used" + FieldResult = "result" + FieldErrorType = "error_type" + FieldComponent = "component" +) + +// traceKey is the context key for trace IDs. +type traceKey struct{} + +// WithTraceID returns a new context carrying the given trace ID. +func WithTraceID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, traceKey{}, id) +} + +// TraceIDFromCtx extracts the trace ID from ctx, or "" if absent. +func TraceIDFromCtx(ctx context.Context) string { + if v, ok := ctx.Value(traceKey{}).(string); ok { + return v + } + return "" +} + +// LoggerConfig configures a per-agent logger. +type LoggerConfig struct { + BaseDir string // root log directory (default: "logs"); empty → stdout only + AgentID string // agent identifier (required) + MaxSizeMB int64 // max file size before rotation (default: 50) + MaxAgeDays int // retention in days (default: 7) + Compress bool // gzip rotated files (default: true) + CleanupInterval time.Duration // cleanup ticker interval (default: 24h) + Level slog.Level // minimum log level (default: INFO) +} + +func (c *LoggerConfig) defaults() { + if c.BaseDir == "" { + c.BaseDir = "logs" + } + if c.MaxSizeMB <= 0 { + c.MaxSizeMB = 50 + } + if c.MaxAgeDays <= 0 { + c.MaxAgeDays = 7 + } + if c.CleanupInterval <= 0 { + c.CleanupInterval = 24 * time.Hour + } +} + +// NewAgentLogger creates a structured JSON logger that writes to daily-rotated +// JSONL files under BaseDir//. It returns: +// - a *slog.Logger pre-enriched with agent_id +// - a cleanup func to call on shutdown (closes files, stops cleanup goroutine) +// - an error if the log directory cannot be created +// +// If BaseDir is literally "stdout", the logger writes to os.Stdout with no +// file rotation or cleanup. +func NewAgentLogger(cfg LoggerConfig) (*slog.Logger, func(), error) { + if cfg.BaseDir == "stdout" { + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Level}) + l := slog.New(h).With(FieldAgentID, cfg.AgentID) + return l, func() {}, nil + } + + cfg.defaults() + + w, err := NewDailyRotatingWriter(cfg.BaseDir, cfg.AgentID, cfg.MaxSizeMB, cfg.Compress) + if err != nil { + return nil, nil, err + } + + h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: cfg.Level}) + l := slog.New(h).With(FieldAgentID, cfg.AgentID) + + ctx, cancel := context.WithCancel(context.Background()) + go runCleanup(ctx, cfg.BaseDir, cfg.AgentID, cfg.MaxAgeDays, cfg.CleanupInterval) + + cleanup := func() { + cancel() + w.Close() + } + + return l, cleanup, nil +} diff --git a/shell/logger/logger_test.go b/shell/logger/logger_test.go new file mode 100644 index 0000000..ea1f2c1 --- /dev/null +++ b/shell/logger/logger_test.go @@ -0,0 +1,77 @@ +package logger + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestNewAgentLogger_WritesJSONL(t *testing.T) { + dir := t.TempDir() + l, cleanup, err := NewAgentLogger(LoggerConfig{ + BaseDir: dir, + AgentID: "test-bot", + Level: slog.LevelDebug, + }) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + l.Info("hello world", FieldAction, "greet", FieldReason, "testing") + + // Force flush by closing. + cleanup() + + files, _ := os.ReadDir(filepath.Join(dir, "test-bot")) + if len(files) == 0 { + t.Fatal("expected at least one log file") + } + + data, err := os.ReadFile(filepath.Join(dir, "test-bot", files[0].Name())) + if err != nil { + t.Fatal(err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("log line is not valid JSON: %s", data) + } + if m["msg"] != "hello world" { + t.Errorf("msg = %v, want hello world", m["msg"]) + } + if m[FieldAgentID] != "test-bot" { + t.Errorf("agent_id = %v, want test-bot", m[FieldAgentID]) + } + if m[FieldAction] != "greet" { + t.Errorf("action = %v, want greet", m[FieldAction]) + } +} + +func TestNewAgentLogger_Stdout(t *testing.T) { + l, cleanup, err := NewAgentLogger(LoggerConfig{ + BaseDir: "stdout", + AgentID: "dev-bot", + }) + if err != nil { + t.Fatal(err) + } + defer cleanup() + // Just verify it doesn't panic. + l.Info("stdout test") +} + +func TestTraceIDContext(t *testing.T) { + ctx := context.Background() + if got := TraceIDFromCtx(ctx); got != "" { + t.Errorf("empty ctx should return empty trace, got %q", got) + } + + ctx = WithTraceID(ctx, "abc-123") + if got := TraceIDFromCtx(ctx); got != "abc-123" { + t.Errorf("trace = %q, want abc-123", got) + } +} diff --git a/shell/logger/query.go b/shell/logger/query.go new file mode 100644 index 0000000..793bdeb --- /dev/null +++ b/shell/logger/query.go @@ -0,0 +1,149 @@ +package logger + +import ( + "bufio" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// ReadLogs returns all log entries for agentID between from and to (inclusive). +func ReadLogs(baseDir, agentID string, from, to time.Time) ([]json.RawMessage, error) { + var result []json.RawMessage + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + entries, err := ReadDayLogs(baseDir, agentID, d) + if err != nil { + continue // skip missing days + } + result = append(result, entries...) + } + return result, nil +} + +// ReadDayLogs returns all log entries for a specific day. +func ReadDayLogs(baseDir, agentID string, date time.Time) ([]json.RawMessage, error) { + dir := filepath.Join(baseDir, agentID) + prefix := date.Format("2006-01-02") + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read dir %s: %w", dir, err) + } + + var result []json.RawMessage + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, prefix) || !isLogFile(name) { + continue + } + lines, err := readLogFile(filepath.Join(dir, name)) + if err != nil { + continue + } + result = append(result, lines...) + } + return result, nil +} + +// SearchLogs returns log entries where field equals value, within the date range. +func SearchLogs(baseDir, agentID string, field, value string, from, to time.Time) ([]json.RawMessage, error) { + all, err := ReadLogs(baseDir, agentID, from, to) + if err != nil { + return nil, err + } + + var matched []json.RawMessage + for _, raw := range all { + var m map[string]any + if json.Unmarshal(raw, &m) != nil { + continue + } + if v, ok := m[field]; ok && fmt.Sprint(v) == value { + matched = append(matched, raw) + } + } + return matched, nil +} + +// ListAgents returns the agent IDs that have log directories under baseDir. +func ListAgents(baseDir string) ([]string, error) { + entries, err := os.ReadDir(baseDir) + if err != nil { + return nil, fmt.Errorf("read base dir %s: %w", baseDir, err) + } + var ids []string + for _, e := range entries { + if e.IsDir() { + ids = append(ids, e.Name()) + } + } + sort.Strings(ids) + return ids, nil +} + +// ListDates returns the dates for which logs exist for the given agent. +func ListDates(baseDir, agentID string) ([]time.Time, error) { + dir := filepath.Join(baseDir, agentID) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read dir %s: %w", dir, err) + } + + seen := make(map[string]bool) + var dates []time.Time + for _, e := range entries { + if e.IsDir() || !isLogFile(e.Name()) { + continue + } + d := parseDateFromFilename(e.Name()) + if d.IsZero() { + continue + } + key := d.Format("2006-01-02") + if !seen[key] { + seen[key] = true + dates = append(dates, d) + } + } + sort.Slice(dates, func(i, j int) bool { return dates[i].Before(dates[j]) }) + return dates, nil +} + +// readLogFile reads all JSONL lines from a file (.jsonl or .jsonl.gz). +func readLogFile(path string) ([]json.RawMessage, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var r io.Reader = f + if strings.HasSuffix(path, ".gz") { + gz, err := gzip.NewReader(f) + if err != nil { + return nil, err + } + defer gz.Close() + r = gz + } + + var lines []json.RawMessage + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + cp := make([]byte, len(line)) + copy(cp, line) + lines = append(lines, json.RawMessage(cp)) + } + return lines, scanner.Err() +} diff --git a/shell/logger/query_test.go b/shell/logger/query_test.go new file mode 100644 index 0000000..ea0ca9e --- /dev/null +++ b/shell/logger/query_test.go @@ -0,0 +1,130 @@ +package logger + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func setupQueryDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + bot1 := filepath.Join(dir, "bot1") + bot2 := filepath.Join(dir, "bot2") + os.MkdirAll(bot1, 0o755) + os.MkdirAll(bot2, 0o755) + + lines := []string{ + `{"time":"2026-03-05T10:00:00Z","level":"INFO","msg":"hello","action":"greet"}`, + `{"time":"2026-03-05T11:00:00Z","level":"ERROR","msg":"oops","action":"fail"}`, + } + os.WriteFile(filepath.Join(bot1, "2026-03-05.jsonl"), + []byte(lines[0]+"\n"+lines[1]+"\n"), 0o644) + os.WriteFile(filepath.Join(bot1, "2026-03-06.jsonl"), + []byte(`{"time":"2026-03-06T09:00:00Z","level":"INFO","msg":"day2"}`+"\n"), 0o644) + + os.WriteFile(filepath.Join(bot2, "2026-03-06.jsonl"), + []byte(`{"time":"2026-03-06T08:00:00Z","level":"DEBUG","msg":"bot2 log"}`+"\n"), 0o644) + + return dir +} + +func TestListAgents(t *testing.T) { + dir := setupQueryDir(t) + agents, err := ListAgents(dir) + if err != nil { + t.Fatal(err) + } + if len(agents) != 2 { + t.Fatalf("expected 2 agents, got %d", len(agents)) + } + if agents[0] != "bot1" || agents[1] != "bot2" { + t.Errorf("agents = %v, want [bot1 bot2]", agents) + } +} + +func TestListDates(t *testing.T) { + dir := setupQueryDir(t) + dates, err := ListDates(dir, "bot1") + if err != nil { + t.Fatal(err) + } + if len(dates) != 2 { + t.Fatalf("expected 2 dates, got %d", len(dates)) + } + if dates[0].Format("2006-01-02") != "2026-03-05" { + t.Errorf("first date = %v, want 2026-03-05", dates[0]) + } +} + +func TestReadDayLogs(t *testing.T) { + dir := setupQueryDir(t) + day := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC) + entries, err := ReadDayLogs(dir, "bot1", day) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } +} + +func TestReadLogs(t *testing.T) { + dir := setupQueryDir(t) + from := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 3, 6, 0, 0, 0, 0, time.UTC) + entries, err := ReadLogs(dir, "bot1", from, to) + if err != nil { + t.Fatal(err) + } + if len(entries) != 3 { + t.Fatalf("expected 3 entries across 2 days, got %d", len(entries)) + } +} + +func TestSearchLogs(t *testing.T) { + dir := setupQueryDir(t) + from := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC) + + results, err := SearchLogs(dir, "bot1", "action", "fail", from, to) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("expected 1 match, got %d", len(results)) + } + var m map[string]any + json.Unmarshal(results[0], &m) + if m["msg"] != "oops" { + t.Errorf("msg = %v, want oops", m["msg"]) + } +} + +func TestSearchLogs_NoMatch(t *testing.T) { + dir := setupQueryDir(t) + from := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 3, 6, 0, 0, 0, 0, time.UTC) + + results, err := SearchLogs(dir, "bot1", "action", "nonexistent", from, to) + if err != nil { + t.Fatal(err) + } + if len(results) != 0 { + t.Errorf("expected 0 matches, got %d", len(results)) + } +} + +func TestListAgents_EmptyDir(t *testing.T) { + dir := t.TempDir() + agents, err := ListAgents(dir) + if err != nil { + t.Fatal(err) + } + if len(agents) != 0 { + t.Errorf("expected 0 agents, got %d", len(agents)) + } +} diff --git a/shell/logger/writer.go b/shell/logger/writer.go new file mode 100644 index 0000000..ea48f84 --- /dev/null +++ b/shell/logger/writer.go @@ -0,0 +1,168 @@ +package logger + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +// DailyRotatingWriter is an io.Writer that rotates log files daily and by +// size. Files are named //YYYY-MM-DD.jsonl with optional +// numeric suffixes for size-based splits within the same day. +type DailyRotatingWriter struct { + baseDir string + agentID string + maxSize int64 // bytes + compress bool + nowFunc func() time.Time // for testing; defaults to time.Now().UTC + dir string // resolved agent log directory + + mu sync.Mutex + current *os.File + written int64 + currentDay string + suffix int +} + +// NewDailyRotatingWriter creates a writer that stores logs under +// baseDir/agentID/. It creates the directory if needed and opens the first +// log file for today. +func NewDailyRotatingWriter(baseDir, agentID string, maxSizeMB int64, compress bool) (*DailyRotatingWriter, error) { + dir := filepath.Join(baseDir, agentID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create log dir %s: %w", dir, err) + } + + w := &DailyRotatingWriter{ + baseDir: baseDir, + agentID: agentID, + maxSize: maxSizeMB * 1024 * 1024, + compress: compress, + nowFunc: func() time.Time { return time.Now().UTC() }, + dir: dir, + } + + if err := w.openFile(); err != nil { + return nil, err + } + return w, nil +} + +// Write implements io.Writer with daily and size-based rotation. +func (w *DailyRotatingWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + today := w.nowFunc().Format("2006-01-02") + + // Day changed → rotate to new day file. + if today != w.currentDay { + if err := w.rotate(today, 0); err != nil { + return 0, err + } + } + + // Size exceeded → split within same day. + if w.written+int64(len(p)) > w.maxSize && w.written > 0 { + w.suffix++ + if err := w.rotate(today, w.suffix); err != nil { + return 0, err + } + } + + n, err := w.current.Write(p) + w.written += int64(n) + return n, err +} + +// Close closes the current log file. +func (w *DailyRotatingWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.current != nil { + return w.current.Close() + } + return nil +} + +// rotate closes the current file (optionally compressing it) and opens a new one. +func (w *DailyRotatingWriter) rotate(day string, suffix int) error { + prev := w.current + prevPath := "" + if prev != nil { + prevPath = prev.Name() + prev.Close() + } + + // Compress the previous file in the background if enabled and it's from a + // different day (we don't compress intra-day splits until day rotates). + if w.compress && prevPath != "" && day != w.currentDay { + go compressFile(prevPath) + } + + w.currentDay = day + w.suffix = suffix + w.written = 0 + + return w.openFile() +} + +// openFile opens (or creates) the log file for the current day/suffix. +func (w *DailyRotatingWriter) openFile() error { + w.currentDay = w.nowFunc().Format("2006-01-02") + name := w.filename(w.currentDay, w.suffix) + + f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open log file %s: %w", name, err) + } + + // Track how much has already been written (append mode). + info, err := f.Stat() + if err != nil { + f.Close() + return err + } + + w.current = f + w.written = info.Size() + return nil +} + +// filename returns the full path for a given day and suffix. +func (w *DailyRotatingWriter) filename(day string, suffix int) string { + if suffix == 0 { + return filepath.Join(w.dir, day+".jsonl") + } + return filepath.Join(w.dir, fmt.Sprintf("%s.%d.jsonl", day, suffix)) +} + +// compressFile gzips src to src.gz and removes the original. +func compressFile(src string) { + in, err := os.Open(src) + if err != nil { + return + } + defer in.Close() + + out, err := os.Create(src + ".gz") + if err != nil { + return + } + + gz := gzip.NewWriter(out) + if _, err := io.Copy(gz, in); err != nil { + gz.Close() + out.Close() + os.Remove(src + ".gz") + return + } + gz.Close() + out.Close() + in.Close() + os.Remove(src) +} diff --git a/shell/logger/writer_test.go b/shell/logger/writer_test.go new file mode 100644 index 0000000..38cb78e --- /dev/null +++ b/shell/logger/writer_test.go @@ -0,0 +1,98 @@ +package logger + +import ( + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestDailyRotatingWriter_DayRotation(t *testing.T) { + dir := t.TempDir() + w, err := NewDailyRotatingWriter(dir, "bot1", 50, false) + if err != nil { + t.Fatal(err) + } + + day1 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC) + + w.nowFunc = func() time.Time { return day1 } + // Force re-open with correct day. + w.current.Close() + w.currentDay = "" + w.openFile() + + w.Write([]byte(`{"msg":"day1"}`)) + + w.nowFunc = func() time.Time { return day2 } + w.Write([]byte(`{"msg":"day2"}`)) + w.Close() + + agentDir := filepath.Join(dir, "bot1") + entries, _ := os.ReadDir(agentDir) + + names := make(map[string]bool) + for _, e := range entries { + names[e.Name()] = true + } + + if !names["2026-03-05.jsonl"] && !names["2026-03-05.jsonl.gz"] { + t.Error("expected 2026-03-05.jsonl or .gz") + } + if !names["2026-03-06.jsonl"] { + t.Error("expected 2026-03-06.jsonl") + } +} + +func TestDailyRotatingWriter_SizeRotation(t *testing.T) { + dir := t.TempDir() + // 1 byte max to force rotation on every write. + w, err := NewDailyRotatingWriter(dir, "bot2", 0, false) + if err != nil { + t.Fatal(err) + } + // Override maxSize to a tiny value (can't use 0 MB). + w.maxSize = 10 + + now := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC) + w.nowFunc = func() time.Time { return now } + w.current.Close() + w.currentDay = "" + w.openFile() + + w.Write([]byte(`{"line":1}` + "\n")) + w.Write([]byte(`{"line":2}` + "\n")) + w.Write([]byte(`{"line":3}` + "\n")) + w.Close() + + entries, _ := os.ReadDir(filepath.Join(dir, "bot2")) + if len(entries) < 2 { + t.Errorf("expected multiple files from size rotation, got %d", len(entries)) + } +} + +func TestDailyRotatingWriter_Concurrent(t *testing.T) { + dir := t.TempDir() + w, err := NewDailyRotatingWriter(dir, "bot3", 50, false) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + w.Write([]byte(`{"concurrent":true}` + "\n")) + }() + } + wg.Wait() + + entries, _ := os.ReadDir(filepath.Join(dir, "bot3")) + if len(entries) == 0 { + t.Error("expected at least one log file") + } +}