feat(coderd): return log snapshot for paused tasks (#21771)

Previously the task logs endpoint only worked when the workspace was
running, leaving users unable to view task history after pausing.

This change adds snapshot retrieval with state-based branching: active
tasks fetch live logs from AgentAPI, paused/initializing/pending tasks
return stored snapshots (providing continuity during pause/resume), and
error/unknown states return HTTP 409 Conflict.

The response includes snapshot metadata (snapshot, snapshot_at) to
indicate whether logs are live or historical.

Closes coder/internal#1254
This commit is contained in:
Mathias Fredriksson
2026-01-30 16:09:45 +02:00
committed by GitHub
parent 536bca7ea9
commit 21eabb1d73
8 changed files with 432 additions and 33 deletions
+137 -25
View File
@@ -786,6 +786,30 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
}
// convertAgentAPIMessagesToLogEntries converts AgentAPI messages to
// TaskLogEntry format.
func convertAgentAPIMessagesToLogEntries(messages []agentapisdk.Message) ([]codersdk.TaskLogEntry, error) {
logs := make([]codersdk.TaskLogEntry, 0, len(messages))
for _, m := range messages {
var typ codersdk.TaskLogType
switch m.Role {
case agentapisdk.RoleUser:
typ = codersdk.TaskLogTypeInput
case agentapisdk.RoleAgent:
typ = codersdk.TaskLogTypeOutput
default:
return nil, xerrors.Errorf("invalid agentapi message role %q", m.Role)
}
logs = append(logs, codersdk.TaskLogEntry{
ID: int(m.Id),
Content: m.Content,
Type: typ,
Time: m.Time,
})
}
return logs, nil
}
// @Summary Get AI task logs
// @ID get-ai-task-logs
// @Security CoderSessionToken
@@ -799,8 +823,42 @@ func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
task := httpmw.TaskParam(r)
switch task.Status {
case database.TaskStatusActive:
// Active tasks: fetch live logs from AgentAPI.
out, err := api.fetchLiveTaskLogs(r, task)
if err != nil {
httperror.WriteResponseError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, out)
case database.TaskStatusPaused, database.TaskStatusPending, database.TaskStatusInitializing:
// In pause, pending and initializing states, we attempt to fetch
// the snapshot from database to provide continuity.
out, err := api.fetchSnapshotTaskLogs(ctx, task.ID)
if err != nil {
httperror.WriteResponseError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, out)
default:
// Cases: database.TaskStatusError, database.TaskStatusUnknown.
// - Error: snapshot would be stale from previous pause.
// - Unknown: cannot determine reliable state.
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot fetch logs for task in current state.",
Detail: fmt.Sprintf("Task status is %q.", task.Status),
})
}
}
func (api *API) fetchLiveTaskLogs(r *http.Request, task database.Task) (codersdk.TaskLogsResponse, error) {
var out codersdk.TaskLogsResponse
if err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
agentAPIClient, err := agentapisdk.NewClient(appURL.String(), agentapisdk.WithHTTPClient(client))
if err != nil {
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
@@ -817,35 +875,89 @@ func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) {
})
}
logs := make([]codersdk.TaskLogEntry, 0, len(messagesResp.Messages))
for _, m := range messagesResp.Messages {
var typ codersdk.TaskLogType
switch m.Role {
case agentapisdk.RoleUser:
typ = codersdk.TaskLogTypeInput
case agentapisdk.RoleAgent:
typ = codersdk.TaskLogTypeOutput
default:
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
Message: "Invalid task app response message role.",
Detail: fmt.Sprintf(`Expected "user" or "agent", got %q.`, m.Role),
})
}
logs = append(logs, codersdk.TaskLogEntry{
ID: int(m.Id),
Content: m.Content,
Type: typ,
Time: m.Time,
logs, err := convertAgentAPIMessagesToLogEntries(messagesResp.Messages)
if err != nil {
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
Message: "Invalid task app response.",
Detail: err.Error(),
})
}
out = codersdk.TaskLogsResponse{Logs: logs}
out = codersdk.TaskLogsResponse{
Logs: logs,
}
return nil
}); err != nil {
httperror.WriteResponseError(ctx, rw, err)
return
})
return out, err
}
func (api *API) fetchSnapshotTaskLogs(ctx context.Context, taskID uuid.UUID) (codersdk.TaskLogsResponse, error) {
snapshot, err := api.Database.GetTaskSnapshot(ctx, taskID)
if err != nil {
if httpapi.IsUnauthorizedError(err) {
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{
Message: "Resource not found.",
})
}
if errors.Is(err, sql.ErrNoRows) {
// No snapshot exists yet, return empty logs. Snapshot is true
// because this field indicates whether the data is from the
// live task app (false) or not (true). Since the task is
// paused/initializing/pending, we cannot fetch live logs, so
// snapshot must be true even with no snapshot data.
return codersdk.TaskLogsResponse{
Logs: []codersdk.TaskLogEntry{},
Snapshot: true,
}, nil
}
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching task snapshot.",
Detail: err.Error(),
})
}
httpapi.Write(ctx, rw, http.StatusOK, out)
// Unmarshal envelope with pre-populated data field to decode once.
envelope := TaskLogSnapshotEnvelope{
Data: &agentapisdk.GetMessagesResponse{},
}
if err := json.Unmarshal(snapshot.LogSnapshot, &envelope); err != nil {
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error decoding task snapshot.",
Detail: err.Error(),
})
}
// Validate snapshot format.
if envelope.Format != "agentapi" {
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Unsupported task snapshot format.",
Detail: fmt.Sprintf("Expected format %q, got %q.", "agentapi", envelope.Format),
})
}
// Extract agentapi data from envelope (already decoded into the correct type).
messagesResp, ok := envelope.Data.(*agentapisdk.GetMessagesResponse)
if !ok {
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error decoding snapshot data.",
Detail: "Unexpected data type in envelope.",
})
}
// Convert agentapi messages to log entries.
logs, err := convertAgentAPIMessagesToLogEntries(messagesResp.Messages)
if err != nil {
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Invalid snapshot data.",
Detail: err.Error(),
})
}
return codersdk.TaskLogsResponse{
Logs: logs,
Snapshot: true,
SnapshotAt: ptr.Ref(snapshot.LogSnapshotCreatedAt),
}, nil
}
// authAndDoWithTaskAppClient centralizes the shared logic to:
+261
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -723,6 +724,266 @@ func TestTasks(t *testing.T) {
})
})
t.Run("LogsWithSnapshot", func(t *testing.T) {
t.Parallel()
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, ownerClient)
ownerUser, err := ownerClient.User(testutil.Context(t, testutil.WaitMedium), owner.UserID.String())
require.NoError(t, err)
ownerSubject := coderdtest.AuthzUserSubject(ownerUser)
// Create a regular user to test snapshot access.
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// Helper to create a task in the desired state.
createTaskInState := func(ctx context.Context, t *testing.T, status database.TaskStatus) uuid.UUID {
ctx = dbauthz.As(ctx, ownerSubject)
builder := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: owner.OrganizationID,
OwnerID: user.ID,
}).
WithTask(database.TaskTable{
OrganizationID: owner.OrganizationID,
OwnerID: user.ID,
}, nil)
switch status {
case database.TaskStatusPending:
builder = builder.Pending()
case database.TaskStatusInitializing:
builder = builder.Starting()
case database.TaskStatusPaused:
builder = builder.Seed(database.WorkspaceBuild{
Transition: database.WorkspaceTransitionStop,
})
case database.TaskStatusError:
// For error state, create a completed build then manipulate app health.
default:
require.Fail(t, "unsupported task status in test helper", "status: %s", status)
}
resp := builder.Do()
taskID := resp.Task.ID
// Post-process by manipulating agent and app state.
if status == database.TaskStatusError {
// First, set agent to ready state so agent_status returns 'active'.
// This ensures the cascade reaches app_status.
err := db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
ID: resp.Agents[0].ID,
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
})
require.NoError(t, err)
// Then set workspace app health to unhealthy to trigger error state.
apps, err := db.GetWorkspaceAppsByAgentID(ctx, resp.Agents[0].ID)
require.NoError(t, err)
require.Len(t, apps, 1, "expected exactly one app for task")
err = db.UpdateWorkspaceAppHealthByID(ctx, database.UpdateWorkspaceAppHealthByIDParams{
ID: apps[0].ID,
Health: database.WorkspaceAppHealthUnhealthy,
})
require.NoError(t, err)
}
return taskID
}
// Prepare snapshot data used across tests.
snapshotMessages := []agentapisdk.Message{
{
Id: 0,
Content: "First message",
Role: agentapisdk.RoleAgent,
Time: time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC),
},
{
Id: 1,
Content: "Second message",
Role: agentapisdk.RoleUser,
Time: time.Date(2025, 1, 1, 10, 1, 0, 0, time.UTC),
},
}
snapshotData := agentapisdk.GetMessagesResponse{
Messages: snapshotMessages,
}
envelope := coderd.TaskLogSnapshotEnvelope{
Format: "agentapi",
Data: snapshotData,
}
snapshotJSON, err := json.Marshal(envelope)
require.NoError(t, err)
snapshotTime := time.Date(2025, 1, 1, 10, 5, 0, 0, time.UTC)
// Helper to verify snapshot logs content.
verifySnapshotLogs := func(t *testing.T, got codersdk.TaskLogsResponse) {
t.Helper()
want := codersdk.TaskLogsResponse{
Snapshot: true,
SnapshotAt: &snapshotTime,
Logs: []codersdk.TaskLogEntry{
{
ID: 0,
Type: codersdk.TaskLogTypeOutput,
Content: "First message",
Time: snapshotMessages[0].Time,
},
{
ID: 1,
Type: codersdk.TaskLogTypeInput,
Content: "Second message",
Time: snapshotMessages[1].Time,
},
},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("got bad response (-want +got):\n%s", diff)
}
}
t.Run("PendingTaskReturnsSnapshot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
taskID := createTaskInState(ctx, t, database.TaskStatusPending)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: taskID,
LogSnapshot: json.RawMessage(snapshotJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err, "upserting task snapshot")
logsResp, err := client.TaskLogs(ctx, "me", taskID)
require.NoError(t, err, "fetching task logs")
verifySnapshotLogs(t, logsResp)
})
t.Run("InitializingTaskReturnsSnapshot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
taskID := createTaskInState(ctx, t, database.TaskStatusInitializing)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: taskID,
LogSnapshot: json.RawMessage(snapshotJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err, "upserting task snapshot")
logsResp, err := client.TaskLogs(ctx, "me", taskID)
require.NoError(t, err, "fetching task logs")
verifySnapshotLogs(t, logsResp)
})
t.Run("PausedTaskReturnsSnapshot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
taskID := createTaskInState(ctx, t, database.TaskStatusPaused)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: taskID,
LogSnapshot: json.RawMessage(snapshotJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err, "upserting task snapshot")
logsResp, err := client.TaskLogs(ctx, "me", taskID)
require.NoError(t, err, "fetching task logs")
verifySnapshotLogs(t, logsResp)
})
t.Run("NoSnapshotReturnsEmpty", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
taskID := createTaskInState(ctx, t, database.TaskStatusPending)
logsResp, err := client.TaskLogs(ctx, "me", taskID)
require.NoError(t, err)
assert.True(t, logsResp.Snapshot)
assert.Nil(t, logsResp.SnapshotAt)
assert.Len(t, logsResp.Logs, 0)
})
t.Run("InvalidSnapshotFormat", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
taskID := createTaskInState(ctx, t, database.TaskStatusPending)
invalidEnvelope := coderd.TaskLogSnapshotEnvelope{
Format: "unknown-format",
Data: map[string]any{},
}
invalidJSON, err := json.Marshal(invalidEnvelope)
require.NoError(t, err)
err = db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: taskID,
LogSnapshot: json.RawMessage(invalidJSON),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err)
_, err = client.TaskLogs(ctx, "me", taskID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Unsupported task snapshot format")
})
t.Run("MalformedSnapshotData", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
taskID := createTaskInState(ctx, t, database.TaskStatusPending)
err := db.UpsertTaskSnapshot(dbauthz.As(ctx, ownerSubject), database.UpsertTaskSnapshotParams{
TaskID: taskID,
LogSnapshot: json.RawMessage(`{"format":"agentapi","data":"not an object"}`),
LogSnapshotCreatedAt: snapshotTime,
})
require.NoError(t, err)
_, err = client.TaskLogs(ctx, "me", taskID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
})
t.Run("ErrorStateReturnsError", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
taskID := createTaskInState(ctx, t, database.TaskStatusError)
_, err := client.TaskLogs(ctx, "me", taskID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Cannot fetch logs for task in current state")
assert.Contains(t, sdkErr.Detail, "error")
})
})
t.Run("UpdateInput", func(t *testing.T) {
tests := []struct {
name string
+6
View File
@@ -18567,6 +18567,12 @@ const docTemplate = `{
"items": {
"$ref": "#/definitions/codersdk.TaskLogEntry"
}
},
"snapshot": {
"type": "boolean"
},
"snapshot_at": {
"type": "string"
}
}
},
+6
View File
@@ -16983,6 +16983,12 @@
"items": {
"$ref": "#/definitions/codersdk.TaskLogEntry"
}
},
"snapshot": {
"type": "boolean"
},
"snapshot_at": {
"type": "string"
}
}
},