Skip to content

feat(ci): follow live job logs#498

Merged
121watts merged 8 commits intomainfrom
watts/dep-4262-live-log-streaming
May 5, 2026
Merged

feat(ci): follow live job logs#498
121watts merged 8 commits intomainfrom
watts/dep-4262-live-log-streaming

Conversation

@121watts
Copy link
Copy Markdown
Contributor

@121watts 121watts commented May 3, 2026

Summary

depot ci logs --follow now behaves like a live terminal tail for Depot CI logs instead of looking blank while the backend is waiting for work to materialize.

The command streams one attempt's persisted logs, reconnects with the server cursor when the stream is interrupted, suppresses replayed rows, and keeps all human status updates on stderr so stdout remains only log content.

Linear: https://linear.app/depot/issue/DEP-4262/m4-add-live-log-streaming-parity

What was happening

depot ci logs only fetched stored logs. That was fine after an attempt finished, but it left active CI runs without the basic terminal workflow users expect: start a run, follow the job, watch new lines arrive.

The roughest cases were the in-between states: a fresh run can exist before jobs are created, a selected job can be queued before an attempt exists, and an active attempt can have no logs yet. In those states, the old behavior either exited too early or looked stuck.

Direct attempt IDs were especially opaque because the CLI did not have run/job status context before opening the stream.

What happens now

depot ci logs --follow uses the new StreamJobAttemptLogs RPC. Attempt IDs stream directly. Positional job IDs are sent as job_id so the API resolves the latest attempt server-side. Run IDs still resolve through GetRunStatus; if a run has multiple jobs, --job remains a workflow job-key selector for that run.

While --follow is waiting, users now see state-specific progress on stderr: waiting for jobs to be created, waiting for a queued job to start, following a selected job/attempt, waiting for logs with the current attempt status, and no-log stream-ended messages. Interactive terminals get a spinner during waits; non-interactive terminals get plain status lines.

For direct attempt or job IDs, the API sends attempt status updates over the stream, so the CLI can show messages like Waiting for logs from attempt g39s51gj45 (status: failed)... instead of a status-free spinner.

Terminal no-log cases are explicit instead of silent. For example, skipped jobs report that no logs were produced, and empty completed streams say the stream ended without receiving logs. Normal log bytes still go to stdout, so piping and redirecting logs stays clean.

The normal non-follow path still uses GetJobAttemptLogs, so stored log fetching remains the same shape while gaining clearer empty-log messages.

Anything else?

This depends on the API streaming RPC in https://github.com/depot/api/pull/3613.

Timestamps, parallel-step grouping, and JSON formatting stay out of this slice. The stream prints the same body-only lines as the existing logs command so this PR gives users live parity first without changing the display contract.


Note

Medium Risk
Adds a new CI log streaming RPC and significantly changes depot ci logs behavior to reconnect, dedupe, and retry while runs/jobs are pending, which could affect log output and error handling for active runs.

Overview
depot ci logs gains a --follow mode that streams live job/attempt logs (and status updates) while keeping log content on stdout and progress/status UX on stderr (spinner in interactive terminals).

This introduces a new StreamJobAttemptLogs RPC and client wrapper with transient-error reconnection, cursor resume, exponential backoff, and duplicate-line suppression, plus new run/job resolution logic that can retry while jobs/attempts are still pending and emits explicit “no logs produced” messages for terminal/skipped cases.

Reviewed by Cursor Bugbot for commit bba90ab. Bugbot is set up for automated code reviews on this repo. Configure here.

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 3, 2026

@121watts 121watts force-pushed the watts/dep-4262-live-log-streaming branch 4 times, most recently from defb9db to 26e222b Compare May 3, 2026 18:00
@121watts 121watts force-pushed the watts/dep-4262-live-log-streaming branch from 26e222b to 5d4bfce Compare May 3, 2026 18:15
@121watts 121watts marked this pull request as ready for review May 4, 2026 15:24
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Idle timer callback can restart spinner after Stop
    • Added r.lastStatus = "" in stopLocked() to prevent the idle timer callback from restarting the spinner after Stop() is called.
  • ✅ Fixed: Field autoSelected is set but never read
    • Removed the unused autoSelected field from logTarget struct and its assignment in resolveLogTarget function.

Create PR

Or push these changes by commenting:

@cursor push e035df330b
Preview (e035df330b)
diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go
--- a/pkg/cmd/ci/logs.go
+++ b/pkg/cmd/ci/logs.go
@@ -182,7 +182,6 @@
 	jobStatus      string
 	workflowPath   string
 	noLogsMessage  string
-	autoSelected   bool
 	hasAlternates  bool
 	alternateLabel string
 }
@@ -291,6 +290,7 @@
 	}
 	r.spinner.Stop()
 	r.spinner = nil
+	r.lastStatus = ""
 }
 
 type followLogWriter struct {
@@ -575,7 +575,6 @@
 		jobKey:         targetJob.JobKey,
 		jobStatus:      targetJob.Status,
 		workflowPath:   workflowPath,
-		autoSelected:   jobKey == "",
 		hasAlternates:  len(targetJob.Attempts) > 1,
 		alternateLabel: alternateLabel,
 	}, nil

You can send follow-ups to the cloud agent here.

Comment thread pkg/cmd/ci/logs.go
Comment thread pkg/cmd/ci/logs.go Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Backoff not reset for status-only stream messages
    • Moved backoff reset to immediately after stream.Receive() succeeds, ensuring status-only messages also reset the reconnection delay.

Create PR

Or push these changes by commenting:

@cursor push 5ec2a86d7f
Preview (5ec2a86d7f)
diff --git a/pkg/api/ci.go b/pkg/api/ci.go
--- a/pkg/api/ci.go
+++ b/pkg/api/ci.go
@@ -117,6 +117,7 @@
 
 		for stream.Receive() {
 			msg := stream.Msg()
+			backoff = ciStreamInitialBackoff
 			if status := msg.GetAttemptStatus(); status != "" && onStatus != nil {
 				onStatus(status)
 			}
@@ -137,7 +138,6 @@
 					cursor = msg.GetNextCursor()
 				}
 			}
-			backoff = ciStreamInitialBackoff
 		}
 
 		err = stream.Err()

You can send follow-ups to the cloud agent here.

Comment thread pkg/api/ci.go Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Context cancellation produces misleading unresolved ID error
    • Added check for context.Canceled/DeadlineExceeded in streamUnresolvedLogsWithFollowUX to return context error directly instead of wrapping in unresolvedLogStreamError.
  • ✅ Fixed: Dead production code kept alive only by tests
    • Removed resolveAttempt function and updated tests to call resolveLogTarget directly.

Create PR

Or push these changes by commenting:

@cursor push f7b84f439d
Preview (f7b84f439d)
diff --git a/pkg/cmd/ci/logs.go b/pkg/cmd/ci/logs.go
--- a/pkg/cmd/ci/logs.go
+++ b/pkg/cmd/ci/logs.go
@@ -367,6 +367,10 @@
 		return nil
 	}
 
+	if errors.Is(jobErr, context.Canceled) || errors.Is(jobErr, context.DeadlineExceeded) {
+		return jobErr
+	}
+
 	attemptErr := streamLogTargetWithFollowUX(
 		ctx,
 		tokenVal,
@@ -512,20 +516,6 @@
 	return names
 }
 
-// resolveAttempt finds the target attempt from a run status response.
-// It selects a job (by workflow job key flag, by job ID match, or auto-select), then
-// picks the latest attempt and prints informational messages about what was chosen.
-func resolveAttempt(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter string) (string, error) {
-	target, err := resolveLogTarget(resp, originalID, jobKey, workflowFilter)
-	if err != nil {
-		return "", err
-	}
-	if target.attemptID == "" {
-		return "", errors.New(target.noLogsMessage)
-	}
-	return target.attemptID, nil
-}
-
 func resolveLogTarget(resp *civ1.GetRunStatusResponse, originalID, jobKey, workflowFilter string) (logTarget, error) {
 	targetJob, workflowPath, err := findLogsJob(resp, originalID, jobKey, workflowFilter)
 	if err != nil {

diff --git a/pkg/cmd/ci/logs_test.go b/pkg/cmd/ci/logs_test.go
--- a/pkg/cmd/ci/logs_test.go
+++ b/pkg/cmd/ci/logs_test.go
@@ -232,12 +232,12 @@
 		},
 	}
 
-	attemptID, err := resolveAttempt(resp, "run-1", "build", "")
+	target, err := resolveLogTarget(resp, "run-1", "build", "")
 	if err != nil {
 		t.Fatal(err)
 	}
-	if attemptID != "att-2" {
-		t.Fatalf("expected attempt ID %q, got %q", "att-2", attemptID)
+	if target.attemptID != "att-2" {
+		t.Fatalf("expected attempt ID %q, got %q", "att-2", target.attemptID)
 	}
 }
 
@@ -254,7 +254,7 @@
 		},
 	}
 
-	_, err := resolveAttempt(resp, "run-1", "", "")
+	_, err := resolveLogTarget(resp, "run-1", "", "")
 	if err == nil {
 		t.Fatal("expected error for job with no attempts")
 	}
@@ -529,6 +529,33 @@
 	}
 }
 
+func TestStreamUnresolvedLogsWithFollowUXReturnsContextCancellation(t *testing.T) {
+	original := ciStreamJobAttemptLogs
+	t.Cleanup(func() { ciStreamJobAttemptLogs = original })
+
+	ciStreamJobAttemptLogs = func(
+		_ context.Context,
+		_, _ string,
+		_ api.CILogStreamTarget,
+		_ io.Writer,
+		_ func(string),
+	) error {
+		return context.Canceled
+	}
+
+	err := streamUnresolvedLogsWithFollowUX(
+		context.Background(),
+		"token-123",
+		"org-123",
+		"id-123",
+		io.Discard,
+		newLogFollowReporter(io.Discard, false),
+	)
+	if !errors.Is(err, context.Canceled) {
+		t.Fatalf("expected context.Canceled, got %v", err)
+	}
+}
+
 func TestFindLogsJob_SuffixMatch(t *testing.T) {
 	resp := &civ1.GetRunStatusResponse{
 		RunId: "run-1",

You can send follow-ups to the cloud agent here.

Comment thread pkg/cmd/ci/logs.go
Comment thread pkg/cmd/ci/logs.go Outdated
Comment thread pkg/api/ci.go Outdated
Comment thread pkg/cmd/ci/logs.go
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missing context.DeadlineExceeded check in transient error detection
    • Added context.DeadlineExceeded check to isTransientConnectError to prevent client-side deadline expirations from being treated as transient server errors.

Create PR

Or push these changes by commenting:

@cursor push 0745c9cc9b
Preview (0745c9cc9b)
diff --git a/pkg/api/ci.go b/pkg/api/ci.go
--- a/pkg/api/ci.go
+++ b/pkg/api/ci.go
@@ -175,6 +175,9 @@
 	if errors.Is(err, context.Canceled) {
 		return false
 	}
+	if errors.Is(err, context.DeadlineExceeded) {
+		return false
+	}
 	switch connect.CodeOf(err) {
 	case connect.CodeUnavailable, connect.CodeDeadlineExceeded, connect.CodeAborted:
 		return true

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit eeb98e3. Configure here.

Comment thread pkg/api/ci.go
@121watts 121watts merged commit 15514c9 into main May 5, 2026
12 checks passed
@121watts 121watts deleted the watts/dep-4262-live-log-streaming branch May 5, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants