From efcfee80b8e0c3d087b8c5af314a7e9cd51880cf Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 2 Feb 2026 15:50:09 +0200 Subject: [PATCH] feat(cli): show snapshots in task logs (#21787) --- cli/clitest/golden.go | 71 ++++++++ cli/task_logs.go | 26 +++ cli/task_logs_test.go | 160 +++++++++++++++--- cli/task_test.go | 91 ++++++++++ .../Test_TaskLogs_Golden/ByTaskID_JSON.golden | 14 ++ .../ByTaskID_Table.golden | 3 + .../ByTaskName_JSON.golden | 14 ++ .../InitializingTaskSnapshot.golden | 5 + .../SnapshotEmptyLogs.golden | 1 + .../SnapshotWithLogs_JSON.golden | 16 ++ .../SnapshotWithLogs_Table.golden | 5 + .../SnapshotWithSingleMessage.golden | 4 + ...pshotWithoutLogs_NoSnapshotCaptured.golden | 3 + 13 files changed, 389 insertions(+), 24 deletions(-) create mode 100644 cli/testdata/Test_TaskLogs_Golden/ByTaskID_JSON.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/ByTaskID_Table.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/ByTaskName_JSON.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/InitializingTaskSnapshot.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/SnapshotEmptyLogs.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_JSON.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_Table.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden create mode 100644 cli/testdata/Test_TaskLogs_Golden/SnapshotWithoutLogs_NoSnapshotCaptured.golden diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index 19ebebe3c3..1ebdb171a8 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "testing" "github.com/google/go-cmp/cmp" @@ -95,6 +96,76 @@ ExtractCommandPathsLoop: } } +// Output captures stdout and stderr from an invocation and formats them with +// prefixes for golden file testing, preserving their interleaved order. +type Output struct { + mu sync.Mutex + stdout bytes.Buffer + stderr bytes.Buffer + combined bytes.Buffer +} + +// prefixWriter wraps a buffer and prefixes each line with a given prefix. +type prefixWriter struct { + mu *sync.Mutex + prefix string + raw *bytes.Buffer + combined *bytes.Buffer + line bytes.Buffer // buffer for incomplete lines +} + +// Write implements io.Writer, adding a prefix to each complete line. +func (w *prefixWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + // Write unprefixed to raw buffer. + _, _ = w.raw.Write(p) + + // Append to line buffer. + _, _ = w.line.Write(p) + + // Split on newlines. + lines := bytes.Split(w.line.Bytes(), []byte{'\n'}) + + // Write all complete lines (all but the last, which may be incomplete). + for i := 0; i < len(lines)-1; i++ { + _, _ = w.combined.WriteString(w.prefix) + _, _ = w.combined.Write(lines[i]) + _ = w.combined.WriteByte('\n') + } + + // Keep the last line (incomplete) in the buffer. + w.line.Reset() + _, _ = w.line.Write(lines[len(lines)-1]) + + return len(p), nil +} + +// Capture sets up stdout and stderr writers on the invocation that prefix each +// line with "out: " or "err: " while preserving their order. +func Capture(inv *serpent.Invocation) *Output { + output := &Output{} + inv.Stdout = &prefixWriter{mu: &output.mu, prefix: "out: ", raw: &output.stdout, combined: &output.combined} + inv.Stderr = &prefixWriter{mu: &output.mu, prefix: "err: ", raw: &output.stderr, combined: &output.combined} + return output +} + +// Golden returns the formatted output with lines prefixed by "err: " or "out: ". +func (o *Output) Golden() []byte { + return o.combined.Bytes() +} + +// Stdout returns the unprefixed stdout content for parsing (e.g., JSON). +func (o *Output) Stdout() string { + return o.stdout.String() +} + +// Stderr returns the unprefixed stderr content. +func (o *Output) Stderr() string { + return o.stderr.String() +} + // TestGoldenFile will test the given bytes slice input against the // golden file with the given file name, optionally using the given replacements. func TestGoldenFile(t *testing.T, fileName string, actual []byte, replacements map[string]string) { diff --git a/cli/task_logs.go b/cli/task_logs.go index 5e71f75bf8..858ee65e88 100644 --- a/cli/task_logs.go +++ b/cli/task_logs.go @@ -54,12 +54,38 @@ func (r *RootCmd) taskLogs() *serpent.Command { return xerrors.Errorf("get task logs: %w", err) } + // Handle snapshot responses (paused/initializing/pending tasks). + if logs.Snapshot { + if logs.SnapshotAt == nil { + // No snapshot captured yet. + cliui.Warnf(inv.Stderr, + "Task is %s. No snapshot available (snapshot may have failed during pause, resume your task to view logs).\n", + task.Status) + } + + // Snapshot exists with logs, show warning with count. + if len(logs.Logs) > 0 { + if len(logs.Logs) == 1 { + cliui.Warnf(inv.Stderr, "Task is %s. Showing last 1 message from snapshot.\n", task.Status) + } else { + cliui.Warnf(inv.Stderr, "Task is %s. Showing last %d messages from snapshot.\n", task.Status, len(logs.Logs)) + } + } + } + + // Handle empty logs for both snapshot/live, table/json. + if len(logs.Logs) == 0 { + cliui.Infof(inv.Stderr, "No task logs found.") + return nil + } + out, err := formatter.Format(ctx, logs.Logs) if err != nil { return xerrors.Errorf("format task logs: %w", err) } if out == "" { + // Defensive check (shouldn't happen given count check above). cliui.Infof(inv.Stderr, "No task logs found.") return nil } diff --git a/cli/task_logs_test.go b/cli/task_logs_test.go index 33189c5e2b..a9be94a82d 100644 --- a/cli/task_logs_test.go +++ b/cli/task_logs_test.go @@ -19,7 +19,7 @@ import ( "github.com/coder/coder/v2/testutil" ) -func Test_TaskLogs(t *testing.T) { +func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() testMessages := []agentapisdk.Message{ @@ -44,23 +44,20 @@ func Test_TaskLogs(t *testing.T) { client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client // user already has access to their own workspace - var stdout strings.Builder inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json") - inv.Stdout = &stdout + output := clitest.Capture(inv) clitest.SetupConfig(t, userClient, root) err := inv.WithContext(ctx).Run() require.NoError(t, err) + // Verify JSON is valid. var logs []codersdk.TaskLogEntry - err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs) + err = json.NewDecoder(strings.NewReader(output.Stdout())).Decode(&logs) require.NoError(t, err) - require.Len(t, logs, 2) - require.Equal(t, "What is 1 + 1?", logs[0].Content) - require.Equal(t, codersdk.TaskLogTypeInput, logs[0].Type) - require.Equal(t, "2", logs[1].Content) - require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type) + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) }) t.Run("ByTaskID_JSON", func(t *testing.T) { @@ -70,23 +67,20 @@ func Test_TaskLogs(t *testing.T) { client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client - var stdout strings.Builder inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json") - inv.Stdout = &stdout + output := clitest.Capture(inv) clitest.SetupConfig(t, userClient, root) err := inv.WithContext(ctx).Run() require.NoError(t, err) + // Verify JSON is valid. var logs []codersdk.TaskLogEntry - err = json.NewDecoder(strings.NewReader(stdout.String())).Decode(&logs) + err = json.NewDecoder(strings.NewReader(output.Stdout())).Decode(&logs) require.NoError(t, err) - require.Len(t, logs, 2) - require.Equal(t, "What is 1 + 1?", logs[0].Content) - require.Equal(t, codersdk.TaskLogTypeInput, logs[0].Type) - require.Equal(t, "2", logs[1].Content) - require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type) + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) }) t.Run("ByTaskID_Table", func(t *testing.T) { @@ -96,19 +90,15 @@ func Test_TaskLogs(t *testing.T) { client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client - var stdout strings.Builder inv, root := clitest.New(t, "task", "logs", task.ID.String()) - inv.Stdout = &stdout + output := clitest.Capture(inv) clitest.SetupConfig(t, userClient, root) err := inv.WithContext(ctx).Run() require.NoError(t, err) - output := stdout.String() - require.Contains(t, output, "What is 1 + 1?") - require.Contains(t, output, "2") - require.Contains(t, output, "input") - require.Contains(t, output, "output") + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) }) t.Run("TaskNotFound_ByName", func(t *testing.T) { @@ -160,6 +150,128 @@ func Test_TaskLogs(t *testing.T) { err := inv.WithContext(ctx).Run() require.ErrorContains(t, err, assert.AnError.Error()) }) + + t.Run("SnapshotWithLogs_Table", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusPaused, testMessages) + userClient := client + + inv, root := clitest.New(t, "task", "logs", task.Name) + output := clitest.Capture(inv) + clitest.SetupConfig(t, userClient, root) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) + }) + + t.Run("SnapshotWithLogs_JSON", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusPaused, testMessages) + userClient := client + + inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json") + output := clitest.Capture(inv) + clitest.SetupConfig(t, userClient, root) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Verify JSON is valid. + var logs []codersdk.TaskLogEntry + err = json.NewDecoder(strings.NewReader(output.Stdout())).Decode(&logs) + require.NoError(t, err) + + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) + }) + + t.Run("SnapshotWithoutLogs_NoSnapshotCaptured", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + client, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused) + userClient := client + + inv, root := clitest.New(t, "task", "logs", task.Name) + output := clitest.Capture(inv) + clitest.SetupConfig(t, userClient, root) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) + }) + + t.Run("SnapshotWithSingleMessage", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + singleMessage := []agentapisdk.Message{ + { + Id: 0, + Role: agentapisdk.RoleUser, + Content: "Single message", + Time: time.Now(), + }, + } + + client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusPending, singleMessage) + userClient := client + + inv, root := clitest.New(t, "task", "logs", task.Name) + output := clitest.Capture(inv) + clitest.SetupConfig(t, userClient, root) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) + }) + + t.Run("SnapshotEmptyLogs", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusInitializing, []agentapisdk.Message{}) + userClient := client + + inv, root := clitest.New(t, "task", "logs", task.Name) + output := clitest.Capture(inv) + clitest.SetupConfig(t, userClient, root) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) + }) + + t.Run("InitializingTaskSnapshot", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + client, task := setupCLITaskTestWithSnapshot(ctx, t, codersdk.TaskStatusInitializing, testMessages) + userClient := client + + inv, root := clitest.New(t, "task", "logs", task.Name) + output := clitest.Capture(inv) + clitest.SetupConfig(t, userClient, root) + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + // Verify output format with golden file. + clitest.TestGoldenFile(t, t.Name(), output.Golden(), nil) + }) } func fakeAgentAPITaskLogsOK(messages []agentapisdk.Message) map[string]http.HandlerFunc { diff --git a/cli/task_test.go b/cli/task_test.go index ec44930e23..d10bf1958c 100644 --- a/cli/task_test.go +++ b/cli/task_test.go @@ -20,7 +20,11 @@ import ( "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -271,6 +275,93 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st return userClient, task } +// setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot. +func setupCLITaskTestWithSnapshot(ctx context.Context, t *testing.T, status codersdk.TaskStatus, messages []agentapisdk.Message) (*codersdk.Client, codersdk.Task) { + t.Helper() + + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + ownerUser, err := ownerClient.User(ctx, owner.UserID.String()) + require.NoError(t, err) + ownerSubject := coderdtest.AuthzUserSubject(ownerUser) + + task := createTaskInStatus(t, db, owner.OrganizationID, user.ID, status) + + // Create snapshot envelope with agentapi format. + envelope := coderd.TaskLogSnapshotEnvelope{ + Format: "agentapi", + Data: agentapisdk.GetMessagesResponse{ + Messages: messages, + }, + } + snapshotJSON, err := json.Marshal(envelope) + require.NoError(t, err) + + // Insert snapshot into database. + snapshotTime := time.Now() + err = db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{ + TaskID: task.ID, + LogSnapshot: json.RawMessage(snapshotJSON), + LogSnapshotCreatedAt: snapshotTime, + }) + require.NoError(t, err) + + return userClient, task +} + +// setupCLITaskTestWithoutSnapshot creates a task in the specified status without a log snapshot. +func setupCLITaskTestWithoutSnapshot(t *testing.T, status codersdk.TaskStatus) (*codersdk.Client, codersdk.Task) { + t.Helper() + + ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + task := createTaskInStatus(t, db, owner.OrganizationID, user.ID, status) + + return userClient, task +} + +// createTaskInStatus creates a task in the specified status using dbfake. +func createTaskInStatus(t *testing.T, db database.Store, orgID, ownerID uuid.UUID, status codersdk.TaskStatus) codersdk.Task { + t.Helper() + + builder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: orgID, + OwnerID: ownerID, + }). + WithTask(database.TaskTable{ + OrganizationID: orgID, + OwnerID: ownerID, + }, nil) + + switch status { + case codersdk.TaskStatusPending: + builder = builder.Pending() + case codersdk.TaskStatusInitializing: + builder = builder.Starting() + case codersdk.TaskStatusPaused: + builder = builder.Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStop, + }) + default: + require.Fail(t, "unsupported task status in test helper", "status: %s", status) + } + + resp := builder.Do() + + return codersdk.Task{ + ID: resp.Task.ID, + Name: resp.Task.Name, + OrganizationID: resp.Task.OrganizationID, + OwnerID: resp.Task.OwnerID, + WorkspaceID: resp.Task.WorkspaceID, + Status: status, + } +} + // createAITaskTemplate creates a template configured for AI tasks with a sidebar app. func createAITaskTemplate(t *testing.T, client *codersdk.Client, orgID uuid.UUID, opts ...aiTemplateOpt) codersdk.Template { t.Helper() diff --git a/cli/testdata/Test_TaskLogs_Golden/ByTaskID_JSON.golden b/cli/testdata/Test_TaskLogs_Golden/ByTaskID_JSON.golden new file mode 100644 index 0000000000..bef9044eb8 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/ByTaskID_JSON.golden @@ -0,0 +1,14 @@ +out: [ +out: { +out: "id": 0, +out: "content": "What is 1 + 1?", +out: "type": "input", +out: "time": "====[timestamp]=====" +out: }, +out: { +out: "id": 1, +out: "content": "2", +out: "type": "output", +out: "time": "====[timestamp]=====" +out: } +out: ] diff --git a/cli/testdata/Test_TaskLogs_Golden/ByTaskID_Table.golden b/cli/testdata/Test_TaskLogs_Golden/ByTaskID_Table.golden new file mode 100644 index 0000000000..05720612e5 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/ByTaskID_Table.golden @@ -0,0 +1,3 @@ +out: TYPE CONTENT +out: input What is 1 + 1? +out: output 2 diff --git a/cli/testdata/Test_TaskLogs_Golden/ByTaskName_JSON.golden b/cli/testdata/Test_TaskLogs_Golden/ByTaskName_JSON.golden new file mode 100644 index 0000000000..bef9044eb8 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/ByTaskName_JSON.golden @@ -0,0 +1,14 @@ +out: [ +out: { +out: "id": 0, +out: "content": "What is 1 + 1?", +out: "type": "input", +out: "time": "====[timestamp]=====" +out: }, +out: { +out: "id": 1, +out: "content": "2", +out: "type": "output", +out: "time": "====[timestamp]=====" +out: } +out: ] diff --git a/cli/testdata/Test_TaskLogs_Golden/InitializingTaskSnapshot.golden b/cli/testdata/Test_TaskLogs_Golden/InitializingTaskSnapshot.golden new file mode 100644 index 0000000000..b232b203d1 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/InitializingTaskSnapshot.golden @@ -0,0 +1,5 @@ +err: WARN: Task is initializing. Showing last 2 messages from snapshot. +err: +out: TYPE CONTENT +out: input What is 1 + 1? +out: output 2 diff --git a/cli/testdata/Test_TaskLogs_Golden/SnapshotEmptyLogs.golden b/cli/testdata/Test_TaskLogs_Golden/SnapshotEmptyLogs.golden new file mode 100644 index 0000000000..3e86969a28 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/SnapshotEmptyLogs.golden @@ -0,0 +1 @@ +err: No task logs found. diff --git a/cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_JSON.golden b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_JSON.golden new file mode 100644 index 0000000000..fdc58371a4 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_JSON.golden @@ -0,0 +1,16 @@ +err: WARN: Task is paused. Showing last 2 messages from snapshot. +err: +out: [ +out: { +out: "id": 0, +out: "content": "What is 1 + 1?", +out: "type": "input", +out: "time": "====[timestamp]=====" +out: }, +out: { +out: "id": 1, +out: "content": "2", +out: "type": "output", +out: "time": "====[timestamp]=====" +out: } +out: ] diff --git a/cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_Table.golden b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_Table.golden new file mode 100644 index 0000000000..3849cf73c3 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithLogs_Table.golden @@ -0,0 +1,5 @@ +err: WARN: Task is paused. Showing last 2 messages from snapshot. +err: +out: TYPE CONTENT +out: input What is 1 + 1? +out: output 2 diff --git a/cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden new file mode 100644 index 0000000000..3f1013c673 --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden @@ -0,0 +1,4 @@ +err: WARN: Task is initializing. Showing last 1 message from snapshot. +err: +out: TYPE CONTENT +out: input Single message diff --git a/cli/testdata/Test_TaskLogs_Golden/SnapshotWithoutLogs_NoSnapshotCaptured.golden b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithoutLogs_NoSnapshotCaptured.golden new file mode 100644 index 0000000000..3f764424ce --- /dev/null +++ b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithoutLogs_NoSnapshotCaptured.golden @@ -0,0 +1,3 @@ +err: WARN: Task is paused. No snapshot available (snapshot may have failed during pause, resume your task to view logs). +err: +err: No task logs found.