Skip to content

Commit 21eabb1

Browse files
authored
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
1 parent 536bca7 commit 21eabb1

8 files changed

Lines changed: 432 additions & 33 deletions

File tree

‎coderd/aitasks.go‎

Lines changed: 137 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,30 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) {
786786
rw.WriteHeader(http.StatusNoContent)
787787
}
788788

789+
// convertAgentAPIMessagesToLogEntries converts AgentAPI messages to
790+
// TaskLogEntry format.
791+
func convertAgentAPIMessagesToLogEntries(messages []agentapisdk.Message) ([]codersdk.TaskLogEntry, error) {
792+
logs := make([]codersdk.TaskLogEntry, 0, len(messages))
793+
for _, m := range messages {
794+
var typ codersdk.TaskLogType
795+
switch m.Role {
796+
case agentapisdk.RoleUser:
797+
typ = codersdk.TaskLogTypeInput
798+
case agentapisdk.RoleAgent:
799+
typ = codersdk.TaskLogTypeOutput
800+
default:
801+
return nil, xerrors.Errorf("invalid agentapi message role %q", m.Role)
802+
}
803+
logs = append(logs, codersdk.TaskLogEntry{
804+
ID: int(m.Id),
805+
Content: m.Content,
806+
Type: typ,
807+
Time: m.Time,
808+
})
809+
}
810+
return logs, nil
811+
}
812+
789813
// @Summary Get AI task logs
790814
// @ID get-ai-task-logs
791815
// @Security CoderSessionToken
@@ -799,8 +823,42 @@ func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) {
799823
ctx := r.Context()
800824
task := httpmw.TaskParam(r)
801825

826+
switch task.Status {
827+
case database.TaskStatusActive:
828+
// Active tasks: fetch live logs from AgentAPI.
829+
out, err := api.fetchLiveTaskLogs(r, task)
830+
if err != nil {
831+
httperror.WriteResponseError(ctx, rw, err)
832+
return
833+
}
834+
835+
httpapi.Write(ctx, rw, http.StatusOK, out)
836+
837+
case database.TaskStatusPaused, database.TaskStatusPending, database.TaskStatusInitializing:
838+
// In pause, pending and initializing states, we attempt to fetch
839+
// the snapshot from database to provide continuity.
840+
out, err := api.fetchSnapshotTaskLogs(ctx, task.ID)
841+
if err != nil {
842+
httperror.WriteResponseError(ctx, rw, err)
843+
return
844+
}
845+
846+
httpapi.Write(ctx, rw, http.StatusOK, out)
847+
848+
default:
849+
// Cases: database.TaskStatusError, database.TaskStatusUnknown.
850+
// - Error: snapshot would be stale from previous pause.
851+
// - Unknown: cannot determine reliable state.
852+
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
853+
Message: "Cannot fetch logs for task in current state.",
854+
Detail: fmt.Sprintf("Task status is %q.", task.Status),
855+
})
856+
}
857+
}
858+
859+
func (api *API) fetchLiveTaskLogs(r *http.Request, task database.Task) (codersdk.TaskLogsResponse, error) {
802860
var out codersdk.TaskLogsResponse
803-
if err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
861+
err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error {
804862
agentAPIClient, err := agentapisdk.NewClient(appURL.String(), agentapisdk.WithHTTPClient(client))
805863
if err != nil {
806864
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
@@ -817,35 +875,89 @@ func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) {
817875
})
818876
}
819877

820-
logs := make([]codersdk.TaskLogEntry, 0, len(messagesResp.Messages))
821-
for _, m := range messagesResp.Messages {
822-
var typ codersdk.TaskLogType
823-
switch m.Role {
824-
case agentapisdk.RoleUser:
825-
typ = codersdk.TaskLogTypeInput
826-
case agentapisdk.RoleAgent:
827-
typ = codersdk.TaskLogTypeOutput
828-
default:
829-
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
830-
Message: "Invalid task app response message role.",
831-
Detail: fmt.Sprintf(`Expected "user" or "agent", got %q.`, m.Role),
832-
})
833-
}
834-
logs = append(logs, codersdk.TaskLogEntry{
835-
ID: int(m.Id),
836-
Content: m.Content,
837-
Type: typ,
838-
Time: m.Time,
878+
logs, err := convertAgentAPIMessagesToLogEntries(messagesResp.Messages)
879+
if err != nil {
880+
return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{
881+
Message: "Invalid task app response.",
882+
Detail: err.Error(),
839883
})
840884
}
841-
out = codersdk.TaskLogsResponse{Logs: logs}
885+
886+
out = codersdk.TaskLogsResponse{
887+
Logs: logs,
888+
}
842889
return nil
843-
}); err != nil {
844-
httperror.WriteResponseError(ctx, rw, err)
845-
return
890+
})
891+
return out, err
892+
}
893+
894+
func (api *API) fetchSnapshotTaskLogs(ctx context.Context, taskID uuid.UUID) (codersdk.TaskLogsResponse, error) {
895+
snapshot, err := api.Database.GetTaskSnapshot(ctx, taskID)
896+
if err != nil {
897+
if httpapi.IsUnauthorizedError(err) {
898+
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{
899+
Message: "Resource not found.",
900+
})
901+
}
902+
if errors.Is(err, sql.ErrNoRows) {
903+
// No snapshot exists yet, return empty logs. Snapshot is true
904+
// because this field indicates whether the data is from the
905+
// live task app (false) or not (true). Since the task is
906+
// paused/initializing/pending, we cannot fetch live logs, so
907+
// snapshot must be true even with no snapshot data.
908+
return codersdk.TaskLogsResponse{
909+
Logs: []codersdk.TaskLogEntry{},
910+
Snapshot: true,
911+
}, nil
912+
}
913+
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
914+
Message: "Internal error fetching task snapshot.",
915+
Detail: err.Error(),
916+
})
917+
}
918+
919+
// Unmarshal envelope with pre-populated data field to decode once.
920+
envelope := TaskLogSnapshotEnvelope{
921+
Data: &agentapisdk.GetMessagesResponse{},
922+
}
923+
if err := json.Unmarshal(snapshot.LogSnapshot, &envelope); err != nil {
924+
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
925+
Message: "Internal error decoding task snapshot.",
926+
Detail: err.Error(),
927+
})
928+
}
929+
930+
// Validate snapshot format.
931+
if envelope.Format != "agentapi" {
932+
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
933+
Message: "Unsupported task snapshot format.",
934+
Detail: fmt.Sprintf("Expected format %q, got %q.", "agentapi", envelope.Format),
935+
})
936+
}
937+
938+
// Extract agentapi data from envelope (already decoded into the correct type).
939+
messagesResp, ok := envelope.Data.(*agentapisdk.GetMessagesResponse)
940+
if !ok {
941+
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
942+
Message: "Internal error decoding snapshot data.",
943+
Detail: "Unexpected data type in envelope.",
944+
})
945+
}
946+
947+
// Convert agentapi messages to log entries.
948+
logs, err := convertAgentAPIMessagesToLogEntries(messagesResp.Messages)
949+
if err != nil {
950+
return codersdk.TaskLogsResponse{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
951+
Message: "Invalid snapshot data.",
952+
Detail: err.Error(),
953+
})
846954
}
847955

848-
httpapi.Write(ctx, rw, http.StatusOK, out)
956+
return codersdk.TaskLogsResponse{
957+
Logs: logs,
958+
Snapshot: true,
959+
SnapshotAt: ptr.Ref(snapshot.LogSnapshotCreatedAt),
960+
}, nil
849961
}
850962

851963
// authAndDoWithTaskAppClient centralizes the shared logic to:

0 commit comments

Comments
 (0)