Skip to content

Commit 2c9e0cc

Browse files
wesmclaude
andauthored
Persist job logs, unified TUI log viewer, and test/prod isolation (#326)
## Summary - **Job log persistence**: Write agent NDJSON output to per-job log files on disk during execution for post-mortem debugging (`internal/daemon/joblog.go`, `worker.go`) - **Stream formatter**: Rich CLI rendering of NDJSON agent output with colored tool call grouping, markdown text wrapping via glamour, and Codex reasoning item display (`cmd/roborev/streamfmt.go`) - **Unified TUI log viewer**: View agent logs for any job state (running, completed, failed) in the TUI using the same `streamFormatter` as the CLI. Replaces the previous tail-only view with full log rendering, left/right navigation between jobs, and live polling for running jobs - **`/api/job/log` endpoint**: Serves raw NDJSON from disk log files, with startup-race handling for running jobs that haven't written output yet - **Test/prod isolation**: Fix test suite pollution of production `~/.roborev/activity.log` and daemon takeover by `go run` binaries. Add `ProdLogBarrier` hard test barrier that fails the suite if test activity leaks into production logs ## Changes ### Job log persistence - `internal/daemon/joblog.go`: `JobLogPath`, `OpenJobLog`, `CleanJobLogs` for per-job NDJSON files in `~/.roborev/logs/` - `internal/daemon/worker.go`: Tee agent output to disk log during execution - `cmd/roborev/log_cmd.go`: CLI `roborev log <id>` with `--raw` and `--path` flags, `roborev log clean` for manual cleanup ### Stream formatter - `cmd/roborev/streamfmt.go`: Gutter-grouped tool calls (cyan name, dim args), markdown text via glamour, Codex reasoning items (dimmed italic), configurable width for TUI use - `cmd/roborev/log_cmd.go`: `renderJobLog` pipes NDJSON through `streamFormatter`, preserves non-JSON lines (stderr/diagnostics) in all positions ### TUI log viewer - `cmd/roborev/tui.go`: `fetchJobLog` fetches raw NDJSON from `/api/job/log`, renders through `streamFormatter`, displays with scroll/follow. `logVisibleLines()` centralizes viewport calculation accounting for optional command-line header - `cmd/roborev/tui_handlers.go`: `l` key opens log for any job state, left/right navigation between jobs, pgup/pgdown/home/end/g scroll controls - Nil vs empty `logLines` semantics: nil = "Waiting for output...", empty = "(no output)" - Failed jobs with no log file show stored error in flash message ### `/api/job/log` endpoint - `internal/daemon/server.go`: `handleJobLog` serves raw NDJSON with `X-Job-Status` header. Returns empty 200 for running jobs with no log file yet (startup race) ### Test/prod isolation - `internal/daemon/testmain_test.go`: `TestMain` sets `ROBOREV_DATA_DIR` to temp dir, preventing `NewServer` from writing to prod logs - `testmain_test.go`: Root package `TestMain` isolates `e2e_test.go` - `cmd/roborev/main.go`: `shouldRefuseAutoStartDaemon` rejects go-build cache binaries (from `go run`) that would kill the prod daemon via version-mismatch restart - `internal/testenv/barrier.go`: `ProdLogBarrier` snapshots prod log sizes before tests, scans new lines after for test markers (`event:"test"`, `version:"dev"` daemon starts, test PID runtime files). Runs in `TestMain` of all three packages that create `daemon.Server` ## Test plan - [x] `go test ./...` passes with zero prod log pollution - [x] `ProdLogBarrier` unit tests verify detection of all pollution types - [x] Mixed JSON/plain-text log rendering preserves correct ordering - [x] TUI log view errNoLog flash shows job error for failed jobs - [x] Running job empty fetch keeps "Waiting for output..." visible - [x] `logVisibleLines()` accounts for command-line header - [x] Paging keys use consistent viewport calculation - [x] Stream formatter handles Codex reasoning items - [x] Left/right navigation follows global direction convention - [x] EPIPE handling for piped output - [x] Running job with no log file returns empty 200 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 39906d4 commit 2c9e0cc

24 files changed

Lines changed: 3906 additions & 339 deletions

cmd/roborev/log_cmd.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"strconv"
11+
"strings"
12+
"syscall"
13+
"time"
14+
15+
"github.com/roborev-dev/roborev/internal/daemon"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
func logCmd() *cobra.Command {
20+
var (
21+
showPath bool
22+
rawOutput bool
23+
)
24+
25+
cmd := &cobra.Command{
26+
Use: "log <job-id>",
27+
Short: "Show agent output log for a job",
28+
Long: `Show the agent output log for a completed or running job.
29+
30+
By default, JSONL agent output is rendered as human-readable
31+
progress lines (tool calls, agent text). Non-JSON logs are
32+
printed as-is.
33+
34+
Use --raw to print the original log bytes unchanged.
35+
36+
Examples:
37+
roborev log 42 # Human-friendly rendered output
38+
roborev log --raw 42 # Raw log bytes (JSONL)
39+
roborev log --path 42 # Print the log file path`,
40+
Args: cobra.ExactArgs(1),
41+
RunE: func(cmd *cobra.Command, args []string) error {
42+
jobID, err := strconv.ParseInt(args[0], 10, 64)
43+
if err != nil {
44+
return fmt.Errorf("invalid job ID: %w", err)
45+
}
46+
47+
out := cmd.OutOrStdout()
48+
49+
if showPath {
50+
fmt.Fprintln(out, daemon.JobLogPath(jobID))
51+
return nil
52+
}
53+
54+
f, err := os.Open(daemon.JobLogPath(jobID))
55+
if err != nil {
56+
return fmt.Errorf(
57+
"no log for job %d (file: %s)",
58+
jobID, daemon.JobLogPath(jobID),
59+
)
60+
}
61+
defer f.Close()
62+
63+
if rawOutput {
64+
_, err := io.Copy(out, f)
65+
if isBrokenPipe(err) {
66+
return nil
67+
}
68+
if err != nil {
69+
return fmt.Errorf("reading log: %w", err)
70+
}
71+
return nil
72+
}
73+
74+
err = renderJobLog(
75+
f, out, writerIsTerminal(out),
76+
)
77+
if isBrokenPipe(err) {
78+
return nil
79+
}
80+
return err
81+
},
82+
}
83+
84+
cmd.Flags().BoolVar(
85+
&showPath, "path", false,
86+
"print the log file path instead of contents",
87+
)
88+
cmd.Flags().BoolVar(
89+
&rawOutput, "raw", false,
90+
"print raw log bytes without formatting",
91+
)
92+
93+
cmd.AddCommand(logCleanCmd())
94+
return cmd
95+
}
96+
97+
// renderJobLog reads a job log file and writes human-friendly
98+
// output. JSONL lines are processed through streamFormatter for
99+
// compact tool/text rendering. Non-JSON lines are printed as-is.
100+
func renderJobLog(r io.Reader, w io.Writer, isTTY bool) error {
101+
return renderJobLogWith(r, newStreamFormatter(w, isTTY), w)
102+
}
103+
104+
// renderJobLogWith renders a job log using a pre-configured
105+
// streamFormatter. plainW receives non-JSON lines directly.
106+
func renderJobLogWith(
107+
r io.Reader, fmtr *streamFormatter, plainW io.Writer,
108+
) error {
109+
br := bufio.NewReader(r)
110+
for {
111+
line, err := br.ReadString('\n')
112+
// ReadString returns data even on error (e.g. EOF
113+
// without trailing newline), so process before checking.
114+
line = strings.TrimRight(line, "\n\r")
115+
if line != "" {
116+
if looksLikeJSON(line) {
117+
if _, werr := fmtr.Write(
118+
[]byte(line + "\n"),
119+
); werr != nil {
120+
return werr
121+
}
122+
} else {
123+
// Non-JSON lines: sanitize ANSI/control sequences
124+
// to prevent terminal spoofing from agent stderr,
125+
// then print.
126+
line = sanitizeControlKeepNewlines(line)
127+
if _, werr := fmt.Fprintln(plainW, line); werr != nil {
128+
return werr
129+
}
130+
}
131+
} else if err != io.EOF {
132+
// Preserve blank lines for spacing in rendered output.
133+
if _, werr := fmt.Fprintln(plainW); werr != nil {
134+
return werr
135+
}
136+
}
137+
if err == io.EOF {
138+
break
139+
}
140+
if err != nil {
141+
return err
142+
}
143+
}
144+
145+
fmtr.Flush()
146+
return nil
147+
}
148+
149+
// looksLikeJSON returns true if line is a JSON object with a
150+
// non-empty "type" field, matching the stream event format used
151+
// by Claude Code, Codex, and Gemini CLI.
152+
func looksLikeJSON(line string) bool {
153+
for _, c := range line {
154+
switch c {
155+
case ' ', '\t':
156+
continue
157+
case '{':
158+
var probe struct{ Type string }
159+
if json.Unmarshal([]byte(line), &probe) != nil {
160+
return false
161+
}
162+
return probe.Type != ""
163+
default:
164+
return false
165+
}
166+
}
167+
return false
168+
}
169+
170+
// isBrokenPipe returns true if err is a broken pipe (EPIPE) error,
171+
// which happens when output is piped to tools like head that close
172+
// the read end early.
173+
func isBrokenPipe(err error) bool {
174+
return err != nil && errors.Is(err, syscall.EPIPE)
175+
}
176+
177+
func logCleanCmd() *cobra.Command {
178+
var maxDays int
179+
180+
cmd := &cobra.Command{
181+
Use: "clean",
182+
Short: "Remove old job log files",
183+
Long: `Remove job log files older than the specified age.
184+
185+
Examples:
186+
roborev log clean # Remove logs older than 7 days
187+
roborev log clean --days 3 # Remove logs older than 3 days`,
188+
Args: cobra.NoArgs,
189+
RunE: func(cmd *cobra.Command, args []string) error {
190+
if maxDays < 0 || maxDays > 3650 {
191+
return fmt.Errorf(
192+
"--days must be between 0 and 3650",
193+
)
194+
}
195+
maxAge := time.Duration(maxDays) * 24 * time.Hour
196+
n := daemon.CleanJobLogs(maxAge)
197+
fmt.Printf("Removed %d log file(s)\n", n)
198+
return nil
199+
},
200+
}
201+
202+
cmd.Flags().IntVar(
203+
&maxDays, "days", 7,
204+
"remove logs older than this many days",
205+
)
206+
207+
return cmd
208+
}

0 commit comments

Comments
 (0)