diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 0e0e93eb72..6437a2ab9f 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -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: diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 7940bc5272..89d52d0c83 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -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 diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4eba393b85..c44f36be86 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -18567,6 +18567,12 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/codersdk.TaskLogEntry" } + }, + "snapshot": { + "type": "boolean" + }, + "snapshot_at": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 88d67ac8cb..6e9039cea4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16983,6 +16983,12 @@ "items": { "$ref": "#/definitions/codersdk.TaskLogEntry" } + }, + "snapshot": { + "type": "boolean" + }, + "snapshot_at": { + "type": "string" } } }, diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index c4a0cb6141..46f8164d06 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -346,9 +346,13 @@ type TaskLogEntry struct { Time time.Time `json:"time" format:"date-time" table:"time,default_sort"` } -// TaskLogsResponse contains the logs for a task. +// TaskLogsResponse contains task logs and metadata. When snapshot is false, +// logs are fetched live from the task app. When snapshot is true, logs are +// fetched from a stored snapshot captured during pause. type TaskLogsResponse struct { - Logs []TaskLogEntry `json:"logs"` + Logs []TaskLogEntry `json:"logs"` + Snapshot bool `json:"snapshot,omitempty"` + SnapshotAt *time.Time `json:"snapshot_at,omitempty"` } // TaskLogs retrieves logs from the task app. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8ae7819879..a766fe2411 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7802,15 +7802,19 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "time": "2019-08-24T14:15:22Z", "type": "input" } - ] + ], + "snapshot": true, + "snapshot_at": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------|---------------------------------------------------------|----------|--------------|-------------| -| `logs` | array of [codersdk.TaskLogEntry](#codersdktasklogentry) | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|---------------------------------------------------------|----------|--------------|-------------| +| `logs` | array of [codersdk.TaskLogEntry](#codersdktasklogentry) | false | | | +| `snapshot` | boolean | false | | | +| `snapshot_at` | string | false | | | ## codersdk.TaskSendRequest diff --git a/docs/reference/api/tasks.md b/docs/reference/api/tasks.md index 1952e8f023..cbfb47c146 100644 --- a/docs/reference/api/tasks.md +++ b/docs/reference/api/tasks.md @@ -351,7 +351,9 @@ curl -X GET http://coder-server:8080/api/v2/tasks/{user}/{task}/logs \ "time": "2019-08-24T14:15:22Z", "type": "input" } - ] + ], + "snapshot": true, + "snapshot_at": "string" } ``` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 10db5ed9c8..6221efa149 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5008,10 +5008,14 @@ export const TaskLogTypes: TaskLogType[] = ["input", "output"]; // From codersdk/aitasks.go /** - * TaskLogsResponse contains the logs for a task. + * TaskLogsResponse contains task logs and metadata. When snapshot is false, + * logs are fetched live from the task app. When snapshot is true, logs are + * fetched from a stored snapshot captured during pause. */ export interface TaskLogsResponse { readonly logs: readonly TaskLogEntry[]; + readonly snapshot?: boolean; + readonly snapshot_at?: string; } // From codersdk/aitasks.go