diff --git a/CLAUDE.md b/CLAUDE.md index 03f3a40..89cb915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,8 +138,13 @@ cfg := agentfleet.DefaultConfig() // cfg.Fleet.LogDir = "/tmp" (default; empty = no log file) // cfg.TUI.Columns = 3 // cfg.TUI.RefreshRate = 500ms +// cfg.TUI.ShowLogPath = true (render cfg.TUI.LogPath in the log divider) // cfg.Agent.PTYRows = 24 // cfg.Agent.PTYCols = 220 +// cfg.LogFile.Enabled = true +// cfg.LogFile.Path = "" (empty = /agentfleet.log) +// cfg.LogFile.MaxBytes = 10MB (0 = never rotate) +// cfg.LogFile.Backups = 5 (0 = truncate, keep no generations) cfg.Agent = agentfleet.AgentConfigFromTerminal() // read from actual terminal ``` @@ -276,3 +281,20 @@ ANTHROPIC_API_KEY=sk-... go run ./examples/generate-manager/ --generate "Run 5 c ### Log a session Set `FleetConfig.LogDir` to a directory path (e.g., `cfg.Fleet.LogDir = "/var/log/agentfleet"`). The Runner writes to `{LogDir}/agentfleet-{task-id}.log`. Set to `""` to disable. + +### Persist the process log stream + +Session logs (above) record agent PTY traffic. The *process* log stream — whatever the host writes through `slog` — is separate, and `LogFileConfig` covers it: + +```go +logFile, err := agentfleet.OpenLogFile(cfg.LogFile) // nil, nil when Enabled is false +defer logFile.Close() + +logBuf := agentfleet.NewLogBuffer(500) +cfg.TUI.Log = logBuf +cfg.TUI.LogPath = logFile.Path() // divider renders " Logs () " + +logger := slog.New(slog.NewTextHandler(io.MultiWriter(logBuf, logFile), nil)) +``` + +`*LogFile` is nil-safe on every method, so a disabled log file needs no branching at the call site. Rotation is Unix-style: the live file keeps its name and older generations shift down through `.1` … `.N`. diff --git a/README.md b/README.md index b67bb23..79e4a09 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,45 @@ func main() { } ``` +## Log File + +`OpenLogFile` returns a rotating `io.Writer` for the process log stream. Tee it +into whatever handler already feeds the TUI panel and the same lines land on +disk; set `TUI.LogPath` and the panel divider shows where they went. + +```go +cfg := agentfleet.DefaultConfig() +cfg.LogFile.Path = "retask.log" // default: /agentfleet.log + +logFile, err := agentfleet.OpenLogFile(cfg.LogFile) +if err != nil { + return err +} +defer logFile.Close() + +logBuf := agentfleet.NewLogBuffer(500) +cfg.TUI.Log = logBuf +cfg.TUI.LogPath = logFile.Path() + +logger := slog.New(slog.NewTextHandler(io.MultiWriter(logBuf, logFile), nil)) +``` + +``` +── Logs (/work/session-abc/retask.log) ─────────────────────────── +``` + +Rotation follows the Unix convention: the live file keeps its name and older +generations shift down through `retask.log.1`, `retask.log.2`, … up to +`LogFile.Backups` before being discarded. + +| Setting | Default | Meaning | +|---------|---------|---------| +| `LogFile.Enabled` | `true` | Write the log stream to a file. `false` makes `OpenLogFile` return a nil no-op writer. | +| `LogFile.Path` | `/agentfleet.log` | Live log file. Relative paths resolve against the working directory. | +| `LogFile.MaxBytes` | `10MB` | Rotate once the live file exceeds this. `0` disables rotation. | +| `LogFile.Backups` | `5` | Rotated generations kept. `0` truncates instead of keeping any. | +| `TUI.ShowLogPath` | `true` | Render `TUI.LogPath` in the log panel divider. | + ## Examples | Example | Purpose | diff --git a/config.go b/config.go index 39dc012..4674baa 100644 --- a/config.go +++ b/config.go @@ -9,9 +9,10 @@ import ( // Config holds all configuration for a fleet run. type Config struct { - Fleet FleetConfig - TUI TUIConfig - Agent AgentConfig + Fleet FleetConfig + TUI TUIConfig + Agent AgentConfig + LogFile LogFileConfig } // FleetConfig controls task scheduling and I/O paths. @@ -30,6 +31,8 @@ type TUIConfig struct { AutoOpen bool // auto-open a tab for each task when it starts — default: true MaxDoneTasks int // done/failed tasks kept in list; 0 = no limit — default: 10 Log *LogBuffer // nil = no log panel + LogPath string // log file shown in the log panel divider; empty = none + ShowLogPath bool // render LogPath in the divider — default: true OnClose func(taskID string) // called when user presses x on a selected task; nil = no-op FilterLines func([]string) []string // pre-process runner output before preview; nil = default chrome filter } @@ -54,8 +57,14 @@ func DefaultConfig() Config { RefreshRate: 500 * time.Millisecond, AutoOpen: true, MaxDoneTasks: 10, + ShowLogPath: true, }, Agent: AgentConfig{PTYRows: 24, PTYCols: 220}, + LogFile: LogFileConfig{ + Enabled: true, + MaxBytes: DefaultLogMaxBytes, + Backups: DefaultLogBackups, + }, } } diff --git a/go.mod b/go.mod index 2e1c3ce..d24a2f6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hoaitan/agentfleet -go 1.26.4 +go 1.26.5 require ( github.com/charmbracelet/bubbletea v1.3.10 diff --git a/logfile.go b/logfile.go new file mode 100644 index 0000000..51be684 --- /dev/null +++ b/logfile.go @@ -0,0 +1,167 @@ +package agentfleet + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +// DefaultLogFileName is used when LogFileConfig.Path is empty. +const DefaultLogFileName = "agentfleet.log" + +// Default rotation thresholds used by DefaultConfig. +const ( + DefaultLogMaxBytes = 10 << 20 // 10MB + DefaultLogBackups = 5 +) + +// LogFileConfig controls the on-disk copy of the process log stream. +// +// The library never installs a logger of its own: OpenLogFile hands back an +// io.Writer that the caller tees into whatever handler it already uses, so the +// same lines reach the TUI log panel and the file. +type LogFileConfig struct { + Enabled bool // write the log stream to a file — default: true + Path string // log file path; empty = /agentfleet.log + MaxBytes int64 // rotate once the live file exceeds this — default: 10MB; <= 0 disables rotation + Backups int // rotated generations kept (.1 … .N) — default: 5; 0 truncates instead +} + +// LogFile is an io.Writer that appends to a file and rotates it Unix-style: +// the live file keeps its name, and older generations shift down through +// .1, .2, … up to Backups before being discarded. +// +// A nil *LogFile is a valid no-op writer, so callers can pass the result of +// OpenLogFile straight to io.MultiWriter without a nil check. +type LogFile struct { + mu sync.Mutex + path string + maxBytes int64 + backups int + f *os.File + size int64 +} + +// OpenLogFile opens (creating or appending to) the configured log file. +// It returns a nil *LogFile when cfg.Enabled is false — writes to that value +// are discarded, so disabling the file needs no branching at the call site. +func OpenLogFile(cfg LogFileConfig) (lf *LogFile, err error) { + if !cfg.Enabled { + return nil, nil + } + path := cfg.Path + if path == "" { + path = DefaultLogFileName + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve log file path %q: %w", path, err) + } + backups := cfg.Backups + if backups < 0 { + backups = 0 + } + l := &LogFile{path: abs, maxBytes: cfg.MaxBytes, backups: backups} + if err := l.open(); err != nil { + return nil, err + } + return l, nil +} + +// Path returns the absolute path of the live log file, or "" for a nil LogFile. +func (l *LogFile) Path() string { + if l == nil { + return "" + } + return l.path +} + +// Write appends p to the log file, rotating first when the write would push +// the live file past MaxBytes. A single write is never split across two +// generations, so a log line always lands in one file. +func (l *LogFile) Write(p []byte) (n int, err error) { + if l == nil { + return len(p), nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.f == nil { + return 0, os.ErrClosed + } + if l.maxBytes > 0 && l.size > 0 && l.size+int64(len(p)) > l.maxBytes { + if err := l.rotate(); err != nil { + return 0, err + } + } + n, err = l.f.Write(p) + l.size += int64(n) + return n, err +} + +// Close closes the live file. Writes after Close return os.ErrClosed. +func (l *LogFile) Close() error { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.f == nil { + return nil + } + err := l.f.Close() + l.f = nil + return err +} + +// open opens the live file for append and records its current size. +// Callers other than OpenLogFile must hold l.mu. +func (l *LogFile) open() error { + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open log file %q: %w", l.path, err) + } + size := int64(0) + if st, err := f.Stat(); err == nil { + size = st.Size() + } + l.f, l.size = f, size + return nil +} + +// rotate shifts the log generations down and reopens an empty live file. +// The caller must hold l.mu. +func (l *LogFile) rotate() error { + if err := l.f.Close(); err != nil { + return fmt.Errorf("close log file %q: %w", l.path, err) + } + l.f = nil + + if l.backups == 0 { + // No generations kept: start the live file over. + if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove log file %q: %w", l.path, err) + } + return l.open() + } + + // Drop the oldest generation, then shift the rest down: .N-1 -> .N, … , .1 -> .2. + if err := os.Remove(l.backupPath(l.backups)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove log file %q: %w", l.backupPath(l.backups), err) + } + for i := l.backups - 1; i >= 1; i-- { + from, to := l.backupPath(i), l.backupPath(i+1) + if err := os.Rename(from, to); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("rotate log file %q -> %q: %w", from, to, err) + } + } + if err := os.Rename(l.path, l.backupPath(1)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("rotate log file %q -> %q: %w", l.path, l.backupPath(1), err) + } + return l.open() +} + +// backupPath returns the path of the nth rotated generation (.n). +func (l *LogFile) backupPath(n int) string { + return fmt.Sprintf("%s.%d", l.path, n) +} diff --git a/logfile_test.go b/logfile_test.go new file mode 100644 index 0000000..99991c2 --- /dev/null +++ b/logfile_test.go @@ -0,0 +1,212 @@ +package agentfleet_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentfleet "github.com/hoaitan/agentfleet" +) + +func TestOpenLogFileDisabledReturnsNilNoOpWriter(t *testing.T) { + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: false}) + require.NoError(t, err) + require.Nil(t, lf) + + // A nil *LogFile stays usable so callers need no nil check. + n, err := lf.Write([]byte("dropped")) + require.NoError(t, err) + assert.Equal(t, len("dropped"), n) + assert.Equal(t, "", lf.Path()) + assert.NoError(t, lf.Close()) +} + +func TestOpenLogFileResolvesRelativePath(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: "retask.log"}) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + assert.True(t, filepath.IsAbs(lf.Path()), "Path() must be absolute, got %q", lf.Path()) + assert.Equal(t, "retask.log", filepath.Base(lf.Path())) +} + +func TestOpenLogFileDefaultsToAgentfleetLog(t *testing.T) { + t.Chdir(t.TempDir()) + + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true}) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + assert.Equal(t, agentfleet.DefaultLogFileName, filepath.Base(lf.Path())) +} + +func TestLogFileAppendsAcrossOpens(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + + first, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: path}) + require.NoError(t, err) + _, err = first.Write([]byte("one\n")) + require.NoError(t, err) + require.NoError(t, first.Close()) + + second, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: path}) + require.NoError(t, err) + _, err = second.Write([]byte("two\n")) + require.NoError(t, err) + require.NoError(t, second.Close()) + + assert.Equal(t, "one\ntwo\n", readFile(t, path)) +} + +func TestLogFileRotatesAtMaxBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 10, Backups: 2, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + // Each write is 6 bytes, so every second write trips the 10-byte threshold. + for _, line := range []string{"aaaaa\n", "bbbbb\n", "ccccc\n"} { + _, err := lf.Write([]byte(line)) + require.NoError(t, err) + } + + assert.Equal(t, "ccccc\n", readFile(t, path), "live file holds the newest write") + assert.Equal(t, "bbbbb\n", readFile(t, path+".1"), ".1 holds the previous generation") + assert.Equal(t, "aaaaa\n", readFile(t, path+".2"), ".2 holds the oldest kept generation") +} + +func TestLogFileDiscardsGenerationsBeyondBackups(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 4, Backups: 1, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + for _, line := range []string{"aaaaa\n", "bbbbb\n", "ccccc\n"} { + _, err := lf.Write([]byte(line)) + require.NoError(t, err) + } + + assert.Equal(t, "ccccc\n", readFile(t, path)) + assert.Equal(t, "bbbbb\n", readFile(t, path+".1")) + assert.NoFileExists(t, path+".2", "only Backups generations are kept") +} + +func TestLogFileZeroBackupsTruncates(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 4, Backups: 0, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + _, err = lf.Write([]byte("aaaaa\n")) + require.NoError(t, err) + _, err = lf.Write([]byte("bbbbb\n")) + require.NoError(t, err) + + assert.Equal(t, "bbbbb\n", readFile(t, path)) + assert.NoFileExists(t, path+".1") +} + +func TestLogFileZeroMaxBytesNeverRotates(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 0, Backups: 3, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + _, err = lf.Write([]byte(strings.Repeat("x", 4096))) + require.NoError(t, err) + _, err = lf.Write([]byte(strings.Repeat("y", 4096))) + require.NoError(t, err) + + assert.Len(t, readFile(t, path), 8192) + assert.NoFileExists(t, path+".1") +} + +func TestLogFileWriteIsNeverSplitAcrossGenerations(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 8, Backups: 1, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + _, err = lf.Write([]byte("short\n")) + require.NoError(t, err) + long := strings.Repeat("z", 64) + "\n" + n, err := lf.Write([]byte(long)) + require.NoError(t, err) + assert.Equal(t, len(long), n) + + // An oversized line rotates first, then lands whole in the new live file. + assert.Equal(t, long, readFile(t, path)) + assert.Equal(t, "short\n", readFile(t, path+".1")) +} + +func TestLogFileWriteAfterCloseFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: path}) + require.NoError(t, err) + require.NoError(t, lf.Close()) + require.NoError(t, lf.Close(), "Close is idempotent") + + _, err = lf.Write([]byte("nope")) + assert.ErrorIs(t, err, os.ErrClosed) +} + +func TestLogFileConcurrentWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 64, Backups: 3, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + done := make(chan struct{}) + for i := 0; i < 8; i++ { + go func() { + defer func() { done <- struct{}{} }() + for j := 0; j < 50; j++ { + _, _ = lf.Write([]byte("concurrent line\n")) + } + }() + } + for i := 0; i < 8; i++ { + <-done + } +} + +func TestOpenLogFileUnwritablePath(t *testing.T) { + _, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: filepath.Join(t.TempDir(), "missing-dir", "retask.log"), + }) + require.Error(t, err) +} + +func TestDefaultConfigEnablesLogFileAndPathDisplay(t *testing.T) { + cfg := agentfleet.DefaultConfig() + assert.True(t, cfg.LogFile.Enabled) + assert.Equal(t, int64(agentfleet.DefaultLogMaxBytes), cfg.LogFile.MaxBytes) + assert.Equal(t, agentfleet.DefaultLogBackups, cfg.LogFile.Backups) + assert.True(t, cfg.TUI.ShowLogPath) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + return string(b) +} diff --git a/tui/logdivider_test.go b/tui/logdivider_test.go new file mode 100644 index 0000000..0c440b2 --- /dev/null +++ b/tui/logdivider_test.go @@ -0,0 +1,72 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" + + agentfleet "github.com/hoaitan/agentfleet" +) + +func TestLogLabelWithoutPath(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: true} + assert.Equal(t, " Logs ", logLabel(cfg, 120)) +} + +func TestLogLabelPathDisplayDisabled(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: false, LogPath: "/work/retask.log"} + assert.Equal(t, " Logs ", logLabel(cfg, 120)) +} + +func TestLogLabelShowsPath(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: true, LogPath: "/work/session-1/retask.log"} + assert.Equal(t, " Logs (/work/session-1/retask.log) ", logLabel(cfg, 120)) +} + +func TestLogLabelElidesLongPathFromTheLeft(t *testing.T) { + path := "/very/deeply/nested/workspace/session-abc/retask.log" + cfg := agentfleet.TUIConfig{ShowLogPath: true, LogPath: path} + + label := logLabel(cfg, 40) + + assert.LessOrEqual(t, lipgloss.Width(label), 38, "label must leave room for both dashes") + assert.True(t, strings.HasPrefix(label, " Logs (…"), "elided from the left, got %q", label) + assert.True(t, strings.HasSuffix(label, "retask.log) "), "keeps the file name, got %q", label) +} + +func TestLogLabelDropsPathOnNarrowTerminal(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: true, LogPath: "/work/session-1/retask.log"} + assert.Equal(t, " Logs ", logLabel(cfg, 16)) +} + +func TestElideLeftKeepsShortStrings(t *testing.T) { + assert.Equal(t, "retask.log", elideLeft("retask.log", 20)) + assert.Equal(t, "retask.log", elideLeft("retask.log", 10)) +} + +func TestElideLeftFitsWidth(t *testing.T) { + got := elideLeft("/a/b/c/retask.log", 12) + assert.Equal(t, 12, lipgloss.Width(got)) + assert.Equal(t, "…/retask.log", got) +} + +func TestRenderLogDividerFitsTerminalWidth(t *testing.T) { + buf := agentfleet.NewLogBuffer(10) + _, _ = buf.Write([]byte("hello\n")) + m := model{ + termW: 60, + cfg: agentfleet.TUIConfig{ + Log: buf, + LogPath: "/very/deeply/nested/workspace/session-abc/retask.log", + ShowLogPath: true, + }, + } + + rows := strings.Split(renderLog(m, 4, ""), "\n") + + assert.Len(t, rows, 4) + assert.Equal(t, 60, lipgloss.Width(rows[0]), "divider spans exactly the terminal width") + assert.Contains(t, rows[0], "retask.log") +} diff --git a/tui/tui.go b/tui/tui.go index 6f54360..94c060c 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -639,8 +639,8 @@ func renderLog(m model, logH int, invis string) string { start = 0 } - label := " Logs " - dashW := w - len([]rune(label)) - 2 + label := logLabel(m.cfg, w) + dashW := w - lipgloss.Width(label) - 2 if dashW < 0 { dashW = 0 } @@ -659,6 +659,49 @@ func renderLog(m model, logH int, invis string) string { return strings.Join(rows, "\n") } +// minLogPathW is the narrowest elided path still worth showing in the divider. +const minLogPathW = 8 + +// logLabel builds the log panel divider label — " Logs " on its own, or +// " Logs (/path/to/file.log) " once a log file is configured. The path is +// elided from the left, and dropped entirely on a terminal too narrow to hold +// it, so the divider always fits one row. +func logLabel(cfg agentfleet.TUIConfig, w int) string { + const ( + plain = " Logs " + prefix = " Logs (" + suffix = ") " + ) + if !cfg.ShowLogPath || cfg.LogPath == "" { + return plain + } + // Two columns are reserved for the dashes bracketing the label. + avail := w - 2 - lipgloss.Width(prefix) - lipgloss.Width(suffix) + if avail < minLogPathW { + return plain + } + return prefix + elideLeft(cfg.LogPath, avail) + suffix +} + +// elideLeft trims s from the left to at most maxW display columns, marking the +// cut with a leading ellipsis. Paths keep the informative tail that way. +func elideLeft(s string, maxW int) string { + if lipgloss.Width(s) <= maxW || maxW <= 0 { + return s + } + runes := []rune(s) + w, i := 0, len(runes) + for i > 0 { + cw := lipgloss.Width(string(runes[i-1])) + if w+cw > maxW-1 { // one column for the ellipsis + break + } + w += cw + i-- + } + return "…" + string(runes[i:]) +} + // wrapLine splits s into visual segments each at most maxW display columns wide. func wrapLine(s string, maxW int) []string { if maxW <= 0 {