Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 115 additions & 1 deletion cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,28 @@ import (
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/gsd-build/daemon/internal/service"
"github.com/spf13/cobra"
)

type logsOptionsState struct {
sessionID string
taskID string
lastTask bool
since time.Duration
level string
pretty bool
json bool
color string
noColor bool
}

var logsOptions logsOptionsState

func streamLogFile(ctx context.Context, logPath string, out io.Writer, pollInterval time.Duration) error {
fh, err := os.Open(logPath)
if err != nil {
Expand Down Expand Up @@ -65,6 +80,10 @@ var logsCmd = &cobra.Command{
Use: "logs",
Short: "Tail the daemon log file",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateLogsOptions(); err != nil {
return err
}

logPath := service.LogPath()
if _, err := os.Stat(logPath); os.IsNotExist(err) {
return fmt.Errorf("no log file at %s — is the daemon installed?", logPath)
Expand All @@ -77,10 +96,105 @@ var logsCmd = &cobra.Command{
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer stop()

return streamLogFile(ctx, logPath, os.Stdout, 250*time.Millisecond)
if !logsOptions.hasStructuredMode() {
return streamLogFile(ctx, logPath, cmd.OutOrStdout(), 250*time.Millisecond)
}

return renderLogFile(logPath, cmd.OutOrStdout())
},
}

func (opts logsOptionsState) hasStructuredMode() bool {
return opts.sessionID != "" ||
opts.taskID != "" ||
opts.lastTask ||
opts.since > 0 ||
opts.level != "" ||
opts.pretty ||
opts.json ||
opts.color != "auto" ||
opts.noColor
Comment on lines +107 to +116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep color flags from changing the command mode.

hasStructuredMode() treats --color and --no-color as selectors for structured rendering, so gsd-cloud logs --no-color stops tailing the raw file and switches to a one-shot pretty snapshot. These flags should only modify pretty output once structured mode is already selected, or validation should require --pretty.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/logs.go` around lines 107 - 116, hasStructuredMode() currently treats
color and noColor as selectors for structured rendering; remove opts.color !=
"auto" and opts.noColor from the hasStructuredMode() boolean expression so color
flags do not change the mode, and add validation in the command's option
parsing/validation (where logsOptionsState is validated) to return an error if
--color/--no-color is provided without --pretty (i.e., require opts.pretty when
opts.color != "auto" or opts.noColor is true) so color flags only affect pretty
output once structured/pretty mode is explicitly selected.

}

func validateLogsOptions() error {
count := 0
if logsOptions.sessionID != "" {
count++
}
if logsOptions.taskID != "" {
count++
}
if logsOptions.lastTask {
count++
}
if count > 1 {
return fmt.Errorf("--session, --task, and --last-task are mutually exclusive")
}
if logsOptions.pretty && logsOptions.json {
return fmt.Errorf("--pretty and --json are mutually exclusive")
}
switch logsOptions.color {
case string(colorAuto), string(colorAlways), string(colorNever):
default:
return fmt.Errorf("--color must be auto, always, or never")
}
return nil
}

func renderLogFile(logPath string, out io.Writer) error {
data, err := os.ReadFile(logPath)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
filter := logFilter{
TaskID: logsOptions.taskID,
SessionID: logsOptions.sessionID,
Since: logsOptions.since,
Level: logsOptions.level,
}
if logsOptions.lastTask {
filter.TaskID = latestTaskID(lines)
}
Comment on lines +156 to +158
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don't fall back to the entire log when --last-task finds nothing.

If latestTaskID(lines) returns "", filter.TaskID stays empty and filterLogLines() passes every event through. On a log file with no task-scoped entries yet, --last-task would silently dump the full log instead of returning no matches or a clear error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/logs.go` around lines 156 - 158, When logsOptions.lastTask is true and
you call latestTaskID(lines), handle the empty-string case instead of leaving
filter.TaskID blank: check the return value of latestTaskID(lines) and if it's
an empty string, return an error (or exit with a clear message) indicating "no
task-scoped entries found for --last-task" rather than assigning "" to
filter.TaskID (which causes filterLogLines() to match everything); update the
branch that currently sets filter.TaskID to call latestTaskID(lines) and bail
out with a clear error or non-zero exit when the result is empty.

events := filterLogLines(lines, filter)
if logsOptions.json {
for _, event := range events {
if event.raw == "" {
continue
}
if _, err := fmt.Fprintln(out, event.raw); err != nil {
return err
}
}
return nil
}
mode := colorMode(logsOptions.color)
if logsOptions.noColor {
mode = colorNever
}
_, err = io.WriteString(out, renderPrettyTimeline(events, mode))
return err
}

func latestTaskID(lines []string) string {
events := filterLogLines(lines, logFilter{})
for i := len(events) - 1; i >= 0; i-- {
if events[i].TaskID != "" {
return events[i].TaskID
}
}
return ""
}

func init() {
logsCmd.Flags().StringVar(&logsOptions.sessionID, "session", "", "show log events for one session")
logsCmd.Flags().StringVar(&logsOptions.taskID, "task", "", "show log events for one task")
logsCmd.Flags().BoolVar(&logsOptions.lastTask, "last-task", false, "show the latest known task in local logs")
logsCmd.Flags().DurationVar(&logsOptions.since, "since", 0, "limit logs by duration, for example 10m or 2h")
logsCmd.Flags().StringVar(&logsOptions.level, "level", "", "minimum level: debug, info, warn, error")
logsCmd.Flags().BoolVar(&logsOptions.pretty, "pretty", false, "render a human timeline")
logsCmd.Flags().BoolVar(&logsOptions.json, "json", false, "emit filtered structured JSON lines")
logsCmd.Flags().StringVar(&logsOptions.color, "color", "auto", "color mode: auto, always, never")
logsCmd.Flags().BoolVar(&logsOptions.noColor, "no-color", false, "disable color output")
rootCmd.AddCommand(logsCmd)
}
84 changes: 84 additions & 0 deletions cmd/logs_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package cmd

import (
"encoding/json"
"strings"
"time"
)

type logFilter struct {
TaskID string
SessionID string
Since time.Duration
Level string
}

type logEvent struct {
Time string `json:"time"`
Level string `json:"level"`
Msg string `json:"msg"`
Event string `json:"event"`
Phase string `json:"phase"`
TaskID string `json:"taskId"`
SessionID string `json:"sessionId"`
RequestID string `json:"requestId"`
TraceID string `json:"traceId"`
AttemptID string `json:"attemptId"`
AttemptNumber int `json:"attemptNumber"`
TurnKind string `json:"turnKind"`
ElapsedMs int64 `json:"elapsedMs"`
Model string `json:"model"`
Provider string `json:"provider"`
PID int `json:"pid"`
FailureCode string `json:"failureCode"`
Retryable bool `json:"retryable"`
PromptPreview string `json:"promptPreview"`
Cleanup string `json:"cleanup"`
raw string
}

func filterLogLines(lines []string, filter logFilter) []logEvent {
out := make([]logEvent, 0, len(lines))
cutoff := time.Time{}
if filter.Since > 0 {
cutoff = time.Now().Add(-filter.Since)
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var event logEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
continue
}
event.raw = line
if filter.TaskID != "" && event.TaskID != filter.TaskID {
continue
}
if filter.SessionID != "" && event.SessionID != filter.SessionID {
continue
}
if !cutoff.IsZero() {
parsed, err := time.Parse(time.RFC3339Nano, event.Time)
if err == nil && parsed.Before(cutoff) {
continue
}
}
if filter.Level != "" && !levelAtLeast(event.Level, filter.Level) {
continue
}
out = append(out, event)
}
return out
}

func levelAtLeast(got string, want string) bool {
order := map[string]int{"DEBUG": 0, "INFO": 1, "WARN": 2, "ERROR": 3}
gotLevel, gotOK := order[strings.ToUpper(got)]
wantLevel, wantOK := order[strings.ToUpper(want)]
if !gotOK || !wantOK {
return false
}
return gotLevel >= wantLevel
}
111 changes: 111 additions & 0 deletions cmd/logs_pretty.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cmd

import (
"fmt"
"strings"
"time"
)

type colorMode string

const (
colorAuto colorMode = "auto"
colorAlways colorMode = "always"
colorNever colorMode = "never"
)

func renderPrettyTimeline(events []logEvent, mode colorMode) string {
var b strings.Builder
for _, event := range events {
label := prettyPhase(event.Phase)
ts := prettyTime(event.Time)
line := fmt.Sprintf("%s %-18s", ts, label)
if event.TaskID != "" && event.Phase == "task_received" {
line += " task=" + shortID(event.TaskID)
}
if event.PID != 0 {
line += fmt.Sprintf(" pid=%d", event.PID)
}
if event.Model != "" {
line += " model=" + event.Model
}
if event.FailureCode != "" {
line += " " + event.FailureCode
}
if event.Retryable {
line += " retryable"
}
if event.ElapsedMs > 0 {
line += fmt.Sprintf(" elapsed=%.1fs", float64(event.ElapsedMs)/1000)
}
if event.PromptPreview != "" {
line += ` "` + event.PromptPreview + `"`
}
if event.Cleanup != "" {
line += " " + event.Cleanup
}
if mode != colorNever {
line = colorizePrettyLine(line, event.Phase)
}
Comment on lines +47 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

color=auto currently behaves like always.

Every mode except never runs colorizePrettyLine(), so piping pretty logs to a file or another command still injects ANSI escapes. auto needs a TTY check before deciding whether to colorize.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/logs_pretty.go` around lines 47 - 49, The code currently treats any mode
!= colorNever the same and calls colorizePrettyLine(), causing ANSI escapes even
when piping; change the condition around the call in the pretty-printing path so
that colorizePrettyLine(line, event.Phase) is only called when mode==colorAlways
OR when mode==colorAuto AND the output is a TTY (use an isTerminal/isTTY check
on the writer e.g., os.Stdout or the logger output). Update the branch using the
variables mode, colorNever and the function colorizePrettyLine to perform that
TTY check (or introduce a small helper like isTerminal()) so color=auto disables
ANSI when output is not a terminal.

b.WriteString(line)
b.WriteByte('\n')
}
return b.String()
}

func prettyTime(raw string) string {
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return "--:--:--"
}
return parsed.Format("15:04:05")
}

func prettyPhase(phase string) string {
switch phase {
case "task_received":
return "task received"
case "pi_process_started":
return "pi started"
case "prompt_written":
return "prompt written"
case "first_event_seen":
return "first event seen"
case "first_visible_event_seen":
return "first visible event"
case "cleanup_started":
return "cleanup started"
case "cleanup_finished":
return "cleanup finished"
case "task_completed":
return "completed"
case "task_failed":
return "failed"
case "timed_out", "task_timed_out":
return "timed out"
default:
return strings.ReplaceAll(phase, "_", " ")
}
}

func shortID(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}

func colorizePrettyLine(line string, phase string) string {
switch phase {
case "task_completed", "cleanup_finished":
return "\x1b[32m" + line + "\x1b[0m"
case "task_failed", "task_timed_out", "timed_out", "task_lost":
return "\x1b[31m" + line + "\x1b[0m"
case "waiting_input", "retry_scheduled":
return "\x1b[33m" + line + "\x1b[0m"
case "pi_process_started", "prompt_written", "first_event_seen", "first_visible_event_seen":
return "\x1b[36m" + line + "\x1b[0m"
default:
return line
}
}
32 changes: 32 additions & 0 deletions cmd/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,35 @@ func TestStreamLogFileFollowsExistingAndAppendedContent(t *testing.T) {
t.Fatalf("streamLogFile returned error: %v", err)
}
}

func TestFilterLogLinesByTaskAndSession(t *testing.T) {
lines := []string{
`{"time":"2026-04-30T12:30:55Z","level":"INFO","event":"task_lifecycle","phase":"task_received","taskId":"task-1","sessionId":"session-1"}`,
`{"time":"2026-04-30T12:30:56Z","level":"INFO","event":"task_lifecycle","phase":"task_received","taskId":"task-2","sessionId":"session-2"}`,
}
got := filterLogLines(lines, logFilter{TaskID: "task-1"})
if len(got) != 1 || got[0].TaskID != "task-1" {
t.Fatalf("filtered by task = %#v", got)
}
got = filterLogLines(lines, logFilter{SessionID: "session-2"})
if len(got) != 1 || got[0].SessionID != "session-2" {
t.Fatalf("filtered by session = %#v", got)
}
}

func TestPrettyTimelineIncludesPromptPreviewAndFailureCode(t *testing.T) {
events := []logEvent{
{Time: "2026-04-30T12:30:55Z", Event: "task_lifecycle", Phase: "task_received", TaskID: "d1fae004-71f0-481a-9980-0cd6cecf49cb", SessionID: "session-1", PromptPreview: "write the full update spec"},
{Time: "2026-04-30T12:32:26Z", Event: "task_lifecycle", Phase: "timed_out", FailureCode: "no_first_event_timeout", ElapsedMs: 90000},
}
got := renderPrettyTimeline(events, colorNever)
if !strings.Contains(got, "task received") || !strings.Contains(got, `"write the full update spec"`) {
t.Fatalf("missing received line: %s", got)
}
if !strings.Contains(got, "no_first_event_timeout") {
t.Fatalf("missing failure code: %s", got)
}
if strings.Contains(got, "\x1b[") {
t.Fatalf("color escaped in colorNever output: %q", got)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.26.2
require (
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/gsd-build/protocol-go v0.29.1
github.com/gsd-build/protocol-go v0.32.0
github.com/spf13/cobra v1.10.2
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/gsd-build/protocol-go v0.29.1 h1:ZqJUGAuAShdtgXRr82NfV4Vq/gmUgmQVsxRBAMcDlSQ=
github.com/gsd-build/protocol-go v0.29.1/go.mod h1:vECSwMFp59Ihu5ZH4aLF5fuW9zJ4a3ZXCYngmzfBn8s=
github.com/gsd-build/protocol-go v0.32.0 h1:4Vk/8GFH8s539xx01EFENO7snhJkndvnp9OxiANoCSI=
github.com/gsd-build/protocol-go v0.32.0/go.mod h1:vECSwMFp59Ihu5ZH4aLF5fuW9zJ4a3ZXCYngmzfBn8s=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand Down
Loading