Files
coder/coderd/aitasks.go
T
Spike Curtis f5d42a868e refactor(coderd): add related-data selection to workspace queries (#28302)
This is the first of three PRs implementing lite `codersdk.Workspace`
responses ([GRU-82](https://linear.app/codercom/issue/GRU-82),
[RFC](https://app.notion.com/p/coderhq/Lite-Workspace-API-Requests-3aad579be5928060b5e7cc65c539717f)).
A fully populated workspace fans out into a large tree of DB queries
(template, latest build, provisioner job + queue position, resources,
metadata, agents, apps, statuses, scripts, log sources, template
version). Most callers do not need most of that data, and one of these
queries, `GetProvisionerJobsByIDsWithQueuePosition`, is consistently the
top resource consumer in scale tests.

This PR adds the internal plumbing to load only a selected subset of
that tree. It introduces a `workspaceRelated` selection type and threads
it through `workspaceData` and `workspaceBuildsData` down to the
individual query call sites, skipping any query whose data was not
selected.

The selection is a pointer tree that mirrors the parent/child
relationships between the objects: branch nodes are pointers (non-nil
when selected) and leaf nodes are bools. Modeling it this way makes
selecting a child without its parent unrepresentable, which matches how
loading works (a parent must be queried to learn its children's
identifiers), so there is no normalization step to forget.

No behavior change: every existing caller passes a fully populated
selection (`allWorkspaceRelated` / `allLatestBuildRelated`), so
responses are identical. Wiring the HTTP `include_related` query
parameter (Phase 2) and narrowing individual callers (Phase 3) come in
follow-up PRs.

Two notes on the current mapping:
- `queue_position` is modeled as its own node, but only one job query
exists and it always computes the ranking, so selecting `job` runs the
full query today. Splitting off a cheaper queue-position-free job query
is deferred (it needs a new SQL query + `make gen`).
- The workspace-level `LatestAppStatus` is gated on the
`latest_build.resources.agents.apps.statuses` node. It is queried
directly by workspace ID, but a caller selecting it also pulls in the
build chain as ancestors. Happy to decouple this if preferred.

<details>
<summary>Implementation plan</summary>

# Lite Workspace API — Phase 1 Plan

Add an internal configuration structure to the Coderd method that
fetches a
workspace with its related data (`workspaceData`
/`workspaceBuildsData`), thread
it down to the SQL query call sites, and skip ("squash") queries that
are not
requested. Server-internal only. No HTTP query-string parsing (Phase 2)
and no
`wsrelated` struct tags or field nil-ing (Phase 3).

## Scope

- In: config struct mapping the RFC related-data hierarchy; threading
through
`workspaceData` and `workspaceBuildsData`; gating every related-data
query;
comprehensive unit tests with a mocked `database.Store` (gomock/dbmock).
- Out: HTTP `include_related` parsing, codersdk changes, `wsrelated`
tags, docs
autogen, and changing `convertWorkspaces` skip-on-missing-data behavior.
All
existing callers pass an "include all" config, so Phase 1 is a behavior-
  preserving refactor.

## Design

New file `coderd/wsrelateddata.go` with a pointer tree that mirrors the
RFC
hierarchy. Branch nodes are pointers (present when non-nil); leaf nodes
are
bools. Because a child cannot be expressed without its parent, the RFC
"includes all ancestors" rule is a structural invariant of the type: an
illegal
selection is unrepresentable, so no normalization pass exists or is
needed.

```go
type workspaceRelated struct {
    Template    bool
    LatestBuild *latestBuildRelated
}
type latestBuildRelated struct {
    Job             *jobRelated
    Resources       *resourcesRelated
    TemplateVersion bool
}
type jobRelated struct{ QueuePosition bool }
type resourcesRelated struct {
    Metadata bool
    Agents   *agentsRelated
}
type agentsRelated struct {
    Apps       *appsRelated
    Scripts    bool
    LogSources bool
}
type appsRelated struct{ Statuses bool }
```

Helpers:

- `allWorkspaceRelated()` / `allLatestBuildRelated()` construct a fully
populated tree. Passed by all current callers so behavior is unchanged.
- Zero value (nil subtree) includes nothing but the workspace itself.
- `(*latestBuildRelated).appStatuses()` is a nil-safe accessor for the
deep
`apps.statuses` leaf so call sites descend the tree without a guard
chain.

Rationale for the pointer tree over a flat struct plus
`withAncestors()`: the
invariant holds by construction (illegal state is unrepresentable)
instead of
relying on a normalization step a caller must remember.
`workspaceBuildsData`
takes the `*latestBuildRelated` subtree directly.

### Query gating

`workspaceData(ctx, workspaces, cfg)`:

- `GetTemplatesWithFilter` gated on `Template`.
- `GetLatestWorkspaceBuildsByWorkspaceIDs` gated on `LatestBuild`; when
off, skip
  `workspaceBuildsData` entirely (empty builds).
- `GetLatestWorkspaceAppStatusesByWorkspaceIDs` (workspace-level
LatestAppStatus)
  gated on `AppStatuses`.

`workspaceBuildsData(ctx, builds, cfg)` gates each stage:

- jobs (`GetProvisionerJobsByIDsWithQueuePosition`) + eligible
provisioner
daemons gated on `Job`. Only one job query exists and it always computes
the
queue position, so `QueuePosition` does not gate a separate query yet;
it is
modeled for hierarchy/Phase 2 completeness. A cheaper job query without
the
window-function ranking is a worthwhile follow-up but is out of scope
for a
  simple Phase 1 (needs new SQL + sqlc gen).
- resources gated on `Resources`; metadata on `Metadata`; agents on
`Agents`;
apps on `Apps`; app statuses on `AppStatuses`; scripts on `Scripts`; log
  sources on `LogSources`; template versions on `TemplateVersion`.

Downstream `convertWorkspaceBuilds` already tolerates empty slices, so
squashing
queries yields zero/nil fields without extra work.

## Testing (comprehensive, mocked database.Store)

Internal tests (`coderd/wsrelateddata_internal_test.go`) build
`&API{Options: &Options{Database: dbmock.NewMockStore(ctrl)}}` and call
`workspaceData` directly. gomock is strict, so setting EXPECT only on
permitted
queries proves the rest are squashed.

Cases: include-all (every query runs); zero value (no related queries
run);
each single node (template only, latest_build only, job, resources,
metadata,
agents, apps, statuses, scripts, log_sources, template_version,
workspace-level
app statuses); the empty-resources short-circuit; and the constructors
plus the
nil-safe `appStatuses()` accessor at every depth.

## Steps

1. Add `coderd/wsrelateddata.go` (selection tree, constructors,
`appStatuses`).
2. Thread the selection through `workspaceData` and
`workspaceBuildsData`; gate
   queries by descending the tree.
3. Update the callers to pass `allWorkspaceRelated()` /
`allLatestBuildRelated()`.
4. Add unit tests; run `make fmt`, `make lint`, targeted `go test`.

## Resolved decisions

1. Pointer tree over a flat struct + normalizer, so the ancestor rule is
a
   structural invariant.
2. Cheaper queue-position-free job query deferred to a follow-up.

</details>

---

*Generated by Coder Agents on behalf of @spikecurtis.*
2026-08-20 10:40:31 +02:00

1394 lines
44 KiB
Go

package coderd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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"
)
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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
}
}
// Resolve the workspace owner before generating a task name so required
// external auth can be enforced up front. createWorkspace performs the same
// validation, but checking here keeps the Tasks API aligned with the gates
// the UI presents and avoids generating a name for a task that is rejected.
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
}
// Authorize workspace creation before the external auth preflight below.
// createWorkspace re-checks these gates as the authoritative defense, but
// requireWorkspaceOwnerExternalAuth validates (and may refresh or clear) the
// owner's external auth tokens under a system-restricted context. Running it
// before proving the caller may create a workspace for this owner using this
// template would let an unauthorized caller trigger token refresh side
// effects and probe another user's auth state. Mirror the ordering in
// createWorkspace so the side-effectful preflight only runs once the caller
// is authorized.
if _, err := api.preflightWorkspaceCreate(ctx, owner.ID, codersdk.CreateWorkspaceRequest{
TemplateVersionID: req.TemplateVersionID,
}); err != nil {
httperror.WriteResponseError(ctx, rw, err)
return
}
// Required external auth is otherwise only enforced once createWorkspace
// runs. Validate it here so the Tasks API rejects an owner who is missing a
// required provider before any task name generation or row insertion.
if err := api.requireWorkspaceOwnerExternalAuth(ctx, templateVersion, owner.ID); err != nil {
httperror.WriteResponseError(ctx, rw, err)
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,
}
// 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
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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, allWorkspaceRelated())
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
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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}, allWorkspaceRelated())
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)
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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)
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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)
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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 := agentConn.AppHTTPClient()
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"`
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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
}
// Create envelope to store validated payload.
envelope := TaskLogSnapshotEnvelope{
Format: format,
}
switch format {
case "agentapi":
// Validate is a no-op here: agentapisdk.GetMessagesResponse has no
// validate tags.
var payload agentapisdk.GetMessagesResponse
if !httpapi.ReadLimit(ctx, rw, r, taskSnapshotMaxSize, &payload) {
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)
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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,
})
}
// Deprecated: Coder Tasks is deprecated as of v2.36. This route is only
// registered when CODER_ENABLE_AI_TASKS is set, and is planned for removal in
// v2.37.
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,
})
}