mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+137
-25
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+6
@@ -18567,6 +18567,12 @@ const docTemplate = `{
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.TaskLogEntry"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"snapshot_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+6
@@ -16983,6 +16983,12 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.TaskLogEntry"
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"snapshot_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user