package coderd import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "slices" "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog/v3" agentapisdk "github.com/coder/agentapi-sdk-go" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/taskname" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" ) // @Summary Create a new AI task // @ID create-a-new-ai-task // @Security CoderSessionToken // @Accept json // @Produce json // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param request body codersdk.CreateTaskRequest true "Create task request" // @Success 201 {object} codersdk.Task // @Router /tasks/{user} [post] func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() apiKey = httpmw.APIKey(r) auditor = api.Auditor.Load() mems = httpmw.OrganizationMembersParam(r) taskResourceInfo = audit.AdditionalFields{} ) if mems.User != nil { taskResourceInfo.WorkspaceOwner = mems.User.Username } aReq, commitAudit := audit.InitRequest[database.TaskTable](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionCreate, AdditionalFields: taskResourceInfo, }) defer commitAudit() var req codersdk.CreateTaskRequest if !httpapi.Read(ctx, rw, r, &req) { return } // Fetch the template version to verify access and whether or not it has an // AI task. templateVersion, err := api.Database.GetTemplateVersionByID(ctx, req.TemplateVersionID) if err != nil { if httpapi.Is404Error(err) { // Avoid using httpapi.ResourceNotFound() here because this is an // input error and 404 would be confusing. httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Template version not found or you do not have access to this resource", }) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template version.", Detail: err.Error(), }) return } aReq.UpdateOrganizationID(templateVersion.OrganizationID) if !templateVersion.HasAITask.Valid || !templateVersion.HasAITask.Bool { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: `Template does not have a valid "coder_ai_task" resource.`, }) return } taskName := req.Name if taskName != "" { if err := codersdk.NameValid(taskName); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Unable to create a Task with the provided name.", Detail: err.Error(), }) return } } taskDisplayName := strings.TrimSpace(req.DisplayName) if taskDisplayName != "" { if len(taskDisplayName) > 64 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Display name must be 64 characters or less.", }) return } } // Generate task name and display name if either is not provided if taskName == "" || taskDisplayName == "" { generatedTaskName := taskname.Generate(ctx, api.Logger, req.Input) if taskName == "" { taskName = generatedTaskName.Name } if taskDisplayName == "" { taskDisplayName = generatedTaskName.DisplayName } } createReq := codersdk.CreateWorkspaceRequest{ Name: taskName, TemplateVersionID: req.TemplateVersionID, TemplateVersionPresetID: req.TemplateVersionPresetID, } var owner workspaceOwner if mems.User != nil { // This user fetch is an optimization path for the most common case of creating a // task for 'Me'. // // This is also required to allow `owners` to create workspaces for users // that are not in an organization. owner = workspaceOwner{ ID: mems.User.ID, Username: mems.User.Username, AvatarURL: mems.User.AvatarURL, } } else { // A task can still be created if the caller can read the organization // member. The organization is required, which can be sourced from the // templateVersion. // // If the caller can find the organization membership in the same org // as the template, then they can continue. orgIndex := slices.IndexFunc(mems.Memberships, func(mem httpmw.OrganizationMember) bool { return mem.OrganizationID == templateVersion.OrganizationID }) if orgIndex == -1 { httpapi.ResourceNotFound(rw) return } member := mems.Memberships[orgIndex] owner = workspaceOwner{ ID: member.UserID, Username: member.Username, AvatarURL: member.AvatarURL, } // Update workspace owner information for audit in case it changed. taskResourceInfo.WorkspaceOwner = owner.Username } // Track insert from preCreateInTX. var dbTaskTable database.TaskTable // Ensure an audit log is created for the workspace creation event. aReqWS, commitAuditWS := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionCreate, AdditionalFields: taskResourceInfo, OrganizationID: templateVersion.OrganizationID, }) defer commitAuditWS() workspace, err := createWorkspace(ctx, aReqWS, apiKey.UserID, api, owner, createReq, &createWorkspaceOptions{ remoteAddr: r.RemoteAddr, // Before creating the workspace, ensure that this task can be created. preCreateInTX: func(ctx context.Context, tx database.Store) error { // Create task record in the database before creating the workspace so that // we can request that the workspace be linked to it after creation. dbTaskTable, err = tx.InsertTask(ctx, database.InsertTaskParams{ ID: uuid.New(), OrganizationID: templateVersion.OrganizationID, OwnerID: owner.ID, Name: taskName, DisplayName: taskDisplayName, WorkspaceID: uuid.NullUUID{}, // Will be set after workspace creation. TemplateVersionID: templateVersion.ID, TemplateParameters: []byte("{}"), Prompt: req.Input, CreatedAt: dbtime.Time(api.Clock.Now()), }) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error creating task.", Detail: err.Error(), }) } return nil }, // After the workspace is created, ensure that the task is linked to it. postCreateInTX: func(ctx context.Context, tx database.Store, workspace database.Workspace) error { // Update the task record with the workspace ID after creation. dbTaskTable, err = tx.UpdateTaskWorkspaceID(ctx, database.UpdateTaskWorkspaceIDParams{ ID: dbTaskTable.ID, WorkspaceID: uuid.NullUUID{ UUID: workspace.ID, Valid: true, }, }) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating task.", Detail: err.Error(), }) } return nil }, }) if err != nil { httperror.WriteResponseError(ctx, rw, err) return } aReq.New = dbTaskTable // Fetch the task to get the additional columns from the view. dbTask, err := api.Database.GetTaskByID(ctx, dbTaskTable.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task.", Detail: err.Error(), }) return } httpapi.Write(ctx, rw, http.StatusCreated, taskFromDBTaskAndWorkspace(dbTask, workspace)) } // taskFromDBTaskAndWorkspace creates a codersdk.Task response from the task // database record and workspace. func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) codersdk.Task { var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle var taskAgentHealth *codersdk.WorkspaceAgentHealth var taskAppHealth *codersdk.WorkspaceAppHealth if dbTask.WorkspaceAgentLifecycleState.Valid { taskAgentLifecycle = ptr.Ref(codersdk.WorkspaceAgentLifecycle(dbTask.WorkspaceAgentLifecycleState.WorkspaceAgentLifecycleState)) } if dbTask.WorkspaceAppHealth.Valid { taskAppHealth = ptr.Ref(codersdk.WorkspaceAppHealth(dbTask.WorkspaceAppHealth.WorkspaceAppHealth)) } // If we have an agent ID from the task, find the agent health info if dbTask.WorkspaceAgentID.Valid { findTaskAgentLoop: for _, resource := range ws.LatestBuild.Resources { for _, agent := range resource.Agents { if agent.ID == dbTask.WorkspaceAgentID.UUID { taskAgentHealth = &agent.Health break findTaskAgentLoop } } } } currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle, taskAppHealth) return codersdk.Task{ ID: dbTask.ID, OrganizationID: dbTask.OrganizationID, OwnerID: dbTask.OwnerID, OwnerName: dbTask.OwnerUsername, OwnerAvatarURL: dbTask.OwnerAvatarUrl, Name: dbTask.Name, DisplayName: dbTask.DisplayName, TemplateID: ws.TemplateID, TemplateVersionID: dbTask.TemplateVersionID, TemplateName: ws.TemplateName, TemplateDisplayName: ws.TemplateDisplayName, TemplateIcon: ws.TemplateIcon, WorkspaceID: dbTask.WorkspaceID, WorkspaceName: ws.Name, WorkspaceBuildNumber: dbTask.WorkspaceBuildNumber.Int32, WorkspaceStatus: ws.LatestBuild.Status, WorkspaceAgentID: dbTask.WorkspaceAgentID, WorkspaceAgentLifecycle: taskAgentLifecycle, WorkspaceAgentHealth: taskAgentHealth, WorkspaceAppID: dbTask.WorkspaceAppID, InitialPrompt: dbTask.Prompt, Status: codersdk.TaskStatus(dbTask.Status), CurrentState: currentState, CreatedAt: dbTask.CreatedAt, UpdatedAt: ws.UpdatedAt, } } // appStatusStateToTaskState converts a WorkspaceAppStatusState to a // TaskState. The two enums mostly share values but "failure" in the // app status maps to "failed" in the public task API. func appStatusStateToTaskState(s codersdk.WorkspaceAppStatusState) codersdk.TaskState { switch s { case codersdk.WorkspaceAppStatusStateFailure: return codersdk.TaskStateFailed default: return codersdk.TaskState(s) } } // deriveTaskCurrentState determines the current state of a task based on the // workspace's latest app status and initialization phase. // Returns nil if no valid state can be determined. func deriveTaskCurrentState( dbTask database.Task, ws codersdk.Workspace, taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle, taskAppHealth *codersdk.WorkspaceAppHealth, ) *codersdk.TaskStateEntry { var currentState *codersdk.TaskStateEntry // Ignore 'latest app status' if it is older than the latest build and the // latest build is a 'start' transition. This ensures that you don't show a // stale app status from a previous build. For stop transitions, there is // still value in showing the latest app status. if ws.LatestAppStatus != nil { if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) { currentState = &codersdk.TaskStateEntry{ Timestamp: ws.LatestAppStatus.CreatedAt, State: appStatusStateToTaskState(ws.LatestAppStatus.State), Message: ws.LatestAppStatus.Message, URI: ws.LatestAppStatus.URI, } } } // If no valid agent state was found for the current build and the task is initializing, // provide a descriptive initialization message. if currentState == nil && dbTask.Status == database.TaskStatusInitializing { message := "Initializing workspace" switch { case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending || ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting: message = fmt.Sprintf("Workspace is %s", ws.LatestBuild.Status) case taskAgentLifecycle != nil: switch { case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleCreated: message = "Agent is connecting" case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleStarting: message = "Agent is starting" case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleReady: if taskAppHealth != nil && *taskAppHealth == codersdk.WorkspaceAppHealthInitializing { message = "App is initializing" } else { // In case the workspace app is not initializing, // the overall task status should be updated accordingly message = "Initializing workspace applications" } default: // In case the workspace agent is not initializing, // the overall task status should be updated accordingly message = "Initializing workspace agent" } } currentState = &codersdk.TaskStateEntry{ Timestamp: ws.LatestBuild.CreatedAt, State: codersdk.TaskStateWorking, Message: message, URI: "", } } return currentState } // @Summary List AI tasks // @ID list-ai-tasks // @Security CoderSessionToken // @Produce json // @Tags Tasks // @Param q query string false "Search query for filtering tasks. Supports: owner:, organization:, status:" // @Success 200 {object} codersdk.TasksListResponse // @Router /tasks [get] func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) // Parse query parameters for filtering tasks. queryStr := r.URL.Query().Get("q") filter, errs := searchquery.Tasks(ctx, api.Database, queryStr, apiKey.UserID) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid task search query.", Validations: errs, }) return } // Fetch all tasks matching the filters from the database. dbTasks, err := api.Database.ListTasks(ctx, filter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching tasks.", Detail: err.Error(), }) return } tasks, err := api.convertTasks(ctx, apiKey.UserID, dbTasks) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting tasks.", Detail: err.Error(), }) return } httpapi.Write(ctx, rw, http.StatusOK, codersdk.TasksListResponse{ Tasks: tasks, Count: len(tasks), }) } // convertTasks converts database tasks to API tasks, enriching them with // workspace information. func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks []database.Task) ([]codersdk.Task, error) { if len(dbTasks) == 0 { return []codersdk.Task{}, nil } // Prepare to batch fetch workspaces. workspaceIDs := make([]uuid.UUID, 0, len(dbTasks)) for _, task := range dbTasks { if !task.WorkspaceID.Valid { return nil, xerrors.New("task has no workspace ID") } workspaceIDs = append(workspaceIDs, task.WorkspaceID.UUID) } // Fetch workspaces for tasks that have workspaces. workspaceRows, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ WorkspaceIds: workspaceIDs, }) if err != nil { return nil, xerrors.Errorf("fetch workspaces: %w", err) } workspaces, err := database.ConvertWorkspaceRows(workspaceRows) if err != nil { return nil, xerrors.Errorf("convert workspace rows: %w", err) } // Gather associated data and convert to API workspaces. data, err := api.workspaceData(ctx, workspaces) if err != nil { return nil, xerrors.Errorf("fetch workspace data: %w", err) } apiWorkspaces, err := convertWorkspaces( ctx, api.Logger, requesterID, workspaces, data, ) if err != nil { return nil, xerrors.Errorf("convert workspaces: %w", err) } workspacesByID := make(map[uuid.UUID]codersdk.Workspace) for _, ws := range apiWorkspaces { workspacesByID[ws.ID] = ws } // Convert tasks to SDK format. result := make([]codersdk.Task, 0, len(dbTasks)) for _, dbTask := range dbTasks { task := taskFromDBTaskAndWorkspace(dbTask, workspacesByID[dbTask.WorkspaceID.UUID]) result = append(result, task) } return result, nil } // @Summary Get AI task by ID or name // @ID get-ai-task-by-id-or-name // @Security CoderSessionToken // @Produce json // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param task path string true "Task ID, or task name" // @Success 200 {object} codersdk.Task // @Router /tasks/{user}/{task} [get] func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) task := httpmw.TaskParam(r) if !task.WorkspaceID.Valid { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task.", Detail: "Task workspace ID is invalid.", }) return } workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace.", Detail: err.Error(), }) return } data, err := api.workspaceData(ctx, []database.Workspace{workspace}) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", Detail: err.Error(), }) return } if len(data.builds) == 0 || len(data.templates) == 0 { httpapi.ResourceNotFound(rw) return } appStatus := codersdk.WorkspaceAppStatus{} if len(data.appStatuses) > 0 { appStatus = data.appStatuses[0] } ws, err := convertWorkspace( ctx, api.Logger, apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, appStatus, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting workspace.", Detail: err.Error(), }) return } taskResp := taskFromDBTaskAndWorkspace(task, ws) httpapi.Write(ctx, rw, http.StatusOK, taskResp) } // @Summary Delete AI task // @ID delete-ai-task // @Security CoderSessionToken // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param task path string true "Task ID, or task name" // @Success 202 // @Router /tasks/{user}/{task} [delete] func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) task := httpmw.TaskParam(r) now := api.Clock.Now() if task.WorkspaceID.Valid { workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task workspace before deleting task.", Detail: err.Error(), }) return } // Construct a request to the workspace build creation handler to // initiate deletion. buildReq := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionDelete, Reason: "Deleted via tasks API", } _, err = api.postWorkspaceBuildsInternal( ctx, apiKey, workspace, buildReq, func(action policy.Action, object rbac.Objecter) bool { return api.Authorize(r, action, object) }, audit.WorkspaceBuildBaggageFromRequest(r), ) if err != nil { httperror.WriteWorkspaceBuildError(ctx, rw, err) return } } // As an implementation detail of the workspace build transition, we also delete // the associated task. This means that we have a race between provisionerdserver // and here with deleting the task. In a real world scenario we'll never lose the // race but we should still handle it anyways. _, err := api.Database.DeleteTask(ctx, database.DeleteTaskParams{ ID: task.ID, DeletedAt: dbtime.Time(now), }) if err != nil && !errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to delete task", Detail: err.Error(), }) return } // Task deleted and delete build created successfully. rw.WriteHeader(http.StatusAccepted) } // @Summary Update AI task input // @ID update-ai-task-input // @Security CoderSessionToken // @Accept json // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param task path string true "Task ID, or task name" // @Param request body codersdk.UpdateTaskInputRequest true "Update task input request" // @Success 204 // @Router /tasks/{user}/{task}/input [patch] func (api *API) taskUpdateInput(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() task = httpmw.TaskParam(r) auditor = api.Auditor.Load() taskResourceInfo = audit.AdditionalFields{} ) aReq, commitAudit := audit.InitRequest[database.TaskTable](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionWrite, AdditionalFields: taskResourceInfo, }) defer commitAudit() aReq.Old = task.TaskTable() aReq.UpdateOrganizationID(task.OrganizationID) var req codersdk.UpdateTaskInputRequest if !httpapi.Read(ctx, rw, r, &req) { return } if strings.TrimSpace(req.Input) == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Task input is required.", }) return } var updatedTask database.TaskTable if err := api.Database.InTx(func(tx database.Store) error { task, err := tx.GetTaskByID(ctx, task.ID) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to fetch task.", Detail: err.Error(), }) } if task.Status != database.TaskStatusPaused { return httperror.NewResponseError(http.StatusConflict, codersdk.Response{ Message: "Unable to update task input, task must be paused.", Detail: "Please stop the task's workspace before updating the input.", }) } updatedTask, err = tx.UpdateTaskPrompt(ctx, database.UpdateTaskPromptParams{ ID: task.ID, Prompt: req.Input, }) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to update task input.", Detail: err.Error(), }) } return nil }, nil); err != nil { httperror.WriteResponseError(ctx, rw, err) return } aReq.New = updatedTask httpapi.Write(ctx, rw, http.StatusNoContent, nil) } // @Summary Send input to AI task // @ID send-input-to-ai-task // @Security CoderSessionToken // @Accept json // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param task path string true "Task ID, or task name" // @Param request body codersdk.TaskSendRequest true "Task input request" // @Success 204 // @Router /tasks/{user}/{task}/send [post] func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() task := httpmw.TaskParam(r) var req codersdk.TaskSendRequest if !httpapi.Read(ctx, rw, r, &req) { return } if req.Input == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Task input is required.", }) return } if 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{ Message: "Failed to create agentapi client.", Detail: err.Error(), }) } statusResp, err := agentAPIClient.GetStatus(ctx) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ Message: "Failed to get status from task app.", Detail: err.Error(), }) } if statusResp.Status != agentapisdk.StatusStable { return httperror.NewResponseError(http.StatusConflict, codersdk.Response{ Message: "Task app is not ready to accept input.", Detail: fmt.Sprintf("Status: %s", statusResp.Status), }) } _, err = agentAPIClient.PostMessage(ctx, agentapisdk.PostMessageParams{ Content: req.Input, Type: agentapisdk.MessageTypeUser, }) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ Message: "Task app rejected the message.", Detail: err.Error(), }) } return nil }); err != nil { httperror.WriteResponseError(ctx, rw, err) return } 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 // @Produce json // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param task path string true "Task ID, or task name" // @Success 200 {object} codersdk.TaskLogsResponse // @Router /tasks/{user}/{task}/logs [get] 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 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{ Message: "Failed to create agentapi client.", Detail: err.Error(), }) } messagesResp, err := agentAPIClient.GetMessages(ctx) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ Message: "Failed to get messages from task app.", Detail: err.Error(), }) } 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, } return nil }) 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(), }) } // 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: // // - Fetch the task workspace // - Authorize ApplicationConnect on the workspace // - Validate the AI task and task app health // - Dial the agent and construct an HTTP client to the apps loopback URL // // The provided callback receives the context, an HTTP client that dials via the // agent, and the base app URL (as a value URL) to perform any request. func (api *API) authAndDoWithTaskAppClient( r *http.Request, task database.Task, do func(ctx context.Context, client *http.Client, appURL *url.URL) error, ) error { ctx := r.Context() if task.Status != database.TaskStatusActive { // Return 409 Conflict for valid requests blocked by current state // (pending/initializing are transitional, paused requires resume). // Return 400 Bad Request for error/unknown states. switch task.Status { case database.TaskStatusPending, database.TaskStatusInitializing: return httperror.NewResponseError(http.StatusConflict, codersdk.Response{ Message: fmt.Sprintf("Task is %s.", task.Status), Detail: "The task is resuming. Wait for the task to become active before sending messages.", }) case database.TaskStatusPaused: return httperror.NewResponseError(http.StatusConflict, codersdk.Response{ Message: "Task is paused.", Detail: "Resume the task to send messages.", }) default: // Default handler for error and unknown status. return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Task must be active.", Detail: fmt.Sprintf("Task status is %q, it must be %q to interact with the task.", task.Status, codersdk.TaskStatusActive), }) } } if !task.WorkspaceID.Valid { return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Task does not have a workspace.", }) } if !task.WorkspaceAppID.Valid { return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Task does not have a workspace app.", }) } workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { if httpapi.Is404Error(err) { return httperror.ErrResourceNotFound } return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace.", Detail: err.Error(), }) } // Connecting to applications requires ApplicationConnect on the workspace. if !api.Authorize(r, policy.ActionApplicationConnect, workspace) { return httperror.ErrResourceNotFound } apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, task.WorkspaceAgentID.UUID) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", Detail: err.Error(), }) } var app *database.WorkspaceApp for _, a := range apps { if a.ID == task.WorkspaceAppID.UUID { app = &a break } } // Build the direct app URL and dial the agent. appURL := app.Url.String if appURL == "" { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Task app URL is not configured.", }) } parsedURL, err := url.Parse(appURL) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error parsing task app URL.", Detail: err.Error(), }) } if parsedURL.Scheme != "http" { return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ Message: "Only http scheme is supported for direct agent-dial.", }) } dialCtx, dialCancel := context.WithTimeout(ctx, time.Second*30) defer dialCancel() agentConn, release, err := api.agentProvider.AgentConn(dialCtx, task.WorkspaceAgentID.UUID) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ Message: "Failed to reach task app endpoint.", Detail: err.Error(), }) } defer release() client := &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return agentConn.DialContext(ctx, network, addr) }, }, } return do(ctx, client, parsedURL) } const ( // taskSnapshotMaxSize is the maximum size for task log snapshots (64KB). // Protects against excessive memory usage and database payload sizes. taskSnapshotMaxSize = 64 * 1024 ) // TaskLogSnapshotEnvelope wraps a task log snapshot with format metadata. type TaskLogSnapshotEnvelope struct { Format string `json:"format"` Data any `json:"data"` } // @Summary Upload task log snapshot // @ID upload-task-log-snapshot // @Security CoderSessionToken // @Accept json // @Tags Tasks // @Param task path string true "Task ID" format(uuid) // @Param format query string true "Snapshot format" enums(agentapi) // @Param request body object true "Raw snapshot payload (structure depends on format parameter)" // @Success 204 // @Router /workspaceagents/me/tasks/{task}/log-snapshot [post] func (api *API) postWorkspaceAgentTaskLogSnapshot(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() latestBuild = httpmw.LatestBuild(r) ) // Parse task ID from path. taskIDStr := chi.URLParam(r, "task") taskID, err := uuid.Parse(taskIDStr) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid task ID format.", Detail: err.Error(), }) return } // Validate format parameter (required). p := httpapi.NewQueryParamParser().RequiredNotEmpty("format") format := p.String(r.URL.Query(), "", "format") p.ErrorExcessParams(r.URL.Query()) if len(p.Errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid query parameters.", Validations: p.Errors, }) return } if format != "agentapi" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid format parameter.", Detail: fmt.Sprintf(`Only "agentapi" format is currently supported, got %q.`, format), }) return } // Verify task exists before reading the potentially large payload. // This prevents DoS attacks where attackers spam large payloads for // non-existent or deleted tasks, forcing us to read 64KB into memory // and do expensive JSON operations before the database rejects it. // The UpsertTaskSnapshot will re-fetch for RBAC validation, but this // early check protects against malicious load. task, err := api.Database.GetTaskByID(ctx, taskID) if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task.", Detail: err.Error(), }) return } // Reject deleted tasks early. if task.DeletedAt.Valid { httpapi.ResourceNotFound(rw) return } // Verify task belongs to this agent's workspace. if !task.WorkspaceID.Valid || task.WorkspaceID.UUID != latestBuild.WorkspaceID { httpapi.ResourceNotFound(rw) return } // Limit payload size to avoid excessive memory or data usage. r.Body = http.MaxBytesReader(rw, r.Body, taskSnapshotMaxSize) // Create envelope to store validated payload. envelope := TaskLogSnapshotEnvelope{ Format: format, } switch format { case "agentapi": var payload agentapisdk.GetMessagesResponse if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to decode request payload.", Detail: err.Error(), }) return } // Verify messages field exists (can be empty array). if payload.Messages == nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid agentapi payload structure.", Detail: `Missing required "messages" field.`, }) return } envelope.Data = payload default: // Defensive branch, we already validated "agentapi" format but may add // more formats in the future. httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid format parameter.", Detail: fmt.Sprintf(`Only "agentapi" format is currently supported, got %q.`, format), }) return } // Marshal envelope with validated payload in a single pass. snapshotJSON, err := json.Marshal(envelope) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create snapshot envelope.", Detail: err.Error(), }) return } // Upsert to database using agent's RBAC context. err = api.Database.UpsertTaskSnapshot(ctx, database.UpsertTaskSnapshotParams{ TaskID: task.ID, LogSnapshot: json.RawMessage(snapshotJSON), LogSnapshotCreatedAt: dbtime.Time(api.Clock.Now()), }) if err != nil { if httpapi.IsUnauthorizedError(err) { httpapi.ResourceNotFound(rw) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error storing snapshot.", Detail: err.Error(), }) return } api.Logger.Debug(ctx, "stored task log snapshot", slog.F("task_id", task.ID), slog.F("workspace_id", latestBuild.WorkspaceID), slog.F("snapshot_size_bytes", len(snapshotJSON))) rw.WriteHeader(http.StatusNoContent) } // @Summary Pause task // @ID pause-task // @Security CoderSessionToken // @Produce json // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param task path string true "Task ID" format(uuid) // @Success 202 {object} codersdk.PauseTaskResponse // @Router /tasks/{user}/{task}/pause [post] func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() apiKey = httpmw.APIKey(r) task = httpmw.TaskParam(r) ) if !task.WorkspaceID.Valid { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Task does not have a workspace.", }) return } workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task workspace.", Detail: err.Error(), }) return } buildReq := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStop, Reason: codersdk.CreateWorkspaceBuildReasonTaskManualPause, } build, err := api.postWorkspaceBuildsInternal( ctx, apiKey, workspace, buildReq, func(action policy.Action, object rbac.Objecter) bool { return api.Authorize(r, action, object) }, audit.WorkspaceBuildBaggageFromRequest(r), ) if err != nil { httperror.WriteWorkspaceBuildError(ctx, rw, err) return } if _, err := api.NotificationsEnqueuer.Enqueue( // nolint:gocritic // Need notifier actor to enqueue notifications. dbauthz.AsNotifier(ctx), workspace.OwnerID, notifications.TemplateTaskPaused, map[string]string{ "task": task.Name, "task_id": task.ID.String(), "workspace": workspace.Name, "pause_reason": "manual", }, "api-task-pause", workspace.ID, workspace.OwnerID, workspace.OrganizationID, ); err != nil { api.Logger.Warn(ctx, "failed to notify of task paused", slog.Error(err), slog.F("task_id", task.ID), slog.F("workspace_id", workspace.ID)) } httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.PauseTaskResponse{ WorkspaceBuild: &build, }) } // @Summary Resume task // @ID resume-task // @Security CoderSessionToken // @Produce json // @Tags Tasks // @Param user path string true "Username, user ID, or 'me' for the authenticated user" // @Param task path string true "Task ID" format(uuid) // @Success 202 {object} codersdk.ResumeTaskResponse // @Router /tasks/{user}/{task}/resume [post] func (api *API) resumeTask(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() apiKey = httpmw.APIKey(r) task = httpmw.TaskParam(r) ) if !task.WorkspaceID.Valid { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Task does not have a workspace.", }) return } workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task workspace.", Detail: err.Error(), }) return } latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task workspace build.", Detail: err.Error(), }) return } job, err := api.Database.GetProvisionerJobByID(ctx, latestBuild.JobID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching task workspace build job.", Detail: err.Error(), }) return } workspaceStatus := codersdk.ConvertWorkspaceStatus( codersdk.ProvisionerJobStatus(job.JobStatus), codersdk.WorkspaceTransition(latestBuild.Transition), ) if workspaceStatus == codersdk.WorkspaceStatusRunning { httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: "Task workspace is already running.", Detail: fmt.Sprintf("Workspace status is %q.", workspaceStatus), }) return } buildReq := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, Reason: codersdk.CreateWorkspaceBuildReasonTaskResume, } build, err := api.postWorkspaceBuildsInternal( ctx, apiKey, workspace, buildReq, func(action policy.Action, object rbac.Objecter) bool { return api.Authorize(r, action, object) }, audit.WorkspaceBuildBaggageFromRequest(r), ) if err != nil { httperror.WriteWorkspaceBuildError(ctx, rw, err) return } if _, err := api.NotificationsEnqueuer.Enqueue( // nolint:gocritic // Need notifier actor to enqueue notifications. dbauthz.AsNotifier(ctx), workspace.OwnerID, notifications.TemplateTaskResumed, map[string]string{ "task": task.Name, "task_id": task.ID.String(), "workspace": workspace.Name, }, "api-task-resume", workspace.ID, workspace.OwnerID, workspace.OrganizationID, ); err != nil { api.Logger.Warn(ctx, "failed to notify of task resumed", slog.Error(err), slog.F("task_id", task.ID), slog.F("workspace_id", workspace.ID)) } httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.ResumeTaskResponse{ WorkspaceBuild: &build, }) }