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:
|
||||
|
||||
Reference in New Issue
Block a user