Ctrl K
ComunidadGuías…

coderd/workspacebuilds.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package coderd

import (
	"cmp"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"net/http"
	"slices"
	"sort"
	"strconv"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"
	"golang.org/x/sync/errgroup"
	"golang.org/x/xerrors"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/coderd/audit"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/db2sdk"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/database/provisionerjobs"
	"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/provisionerdserver"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/rbac/policy"
	"github.com/coder/coder/v2/coderd/telemetry"
	"github.com/coder/coder/v2/coderd/wsbuilder"
	"github.com/coder/coder/v2/coderd/wspubsub"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/codersdk/wsrelated"
)

// @Summary Get workspace build
// @ID get-workspace-build
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Success 200 {object} codersdk.WorkspaceBuild
// @Router /api/v2/workspacebuilds/{workspacebuild} [get]
func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspaceBuild := httpmw.WorkspaceBuildParam(r)
	workspace := httpmw.WorkspaceParam(r)

	data, err := api.workspaceBuildsData(ctx, []database.WorkspaceBuild{workspaceBuild}, wsrelated.AllLatestBuild())
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error getting workspace build data.",
			Detail:  err.Error(),
		})
		return
	}

	// Ensure we have the job and template version for the workspace build.
	// Otherwise we risk a panic in the api.convertWorkspaceBuild call below.
	if len(data.jobs) == 0 {
		httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
			Message: "Internal error getting workspace build data.",
			Detail:  "No job found for workspace build.",
		})
		return
	}

	if len(data.templateVersions) == 0 {
		httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
			Message: "Internal error getting workspace build data.",
			Detail:  "No template version found for workspace build.",
		})
		return
	}

	apiBuild, err := api.convertWorkspaceBuild(
		workspaceBuild,
		workspace,
		data.jobs[0],
		newWorkspaceBuildIndex(
			data.resources,
			data.metadata,
			data.agents,
			data.apps,
			data.appStatuses,
			data.scripts,
			data.logSources,
			nil,
		),
		data.templateVersions[0],
	)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace build.",
			Detail:  err.Error(),
		})
		return
	}

	httpapi.Write(ctx, rw, http.StatusOK, apiBuild)
}

// @Summary Get workspace builds by workspace ID
// @ID get-workspace-builds-by-workspace-id
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param after_id query string false "After ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Param since query string false "Since timestamp" format(date-time)
// @Success 200 {array} codersdk.WorkspaceBuild
// @Router /api/v2/workspaces/{workspace}/builds [get]
func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspace := httpmw.WorkspaceParam(r)

	paginationParams, ok := ParsePagination(rw, r)
	if !ok {
		return
	}

	var since time.Time

	sinceParam := r.URL.Query().Get("since")
	if sinceParam != "" {
		var err error
		since, err = time.Parse(time.RFC3339, sinceParam)
		if err != nil {
			httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
				Message: "bad `since` format, must be RFC3339",
				Detail:  err.Error(),
			})
			return
		}
	}

	var workspaceBuilds []database.WorkspaceBuild
	// Ensure all db calls happen in the same tx
	err := api.Database.InTx(func(store database.Store) error {
		var err error
		if paginationParams.AfterID != uuid.Nil {
			// See if the record exists first. If the record does not exist, the pagination
			// query will not work.
			_, err := store.GetWorkspaceBuildByID(ctx, paginationParams.AfterID)
			if err != nil && xerrors.Is(err, sql.ErrNoRows) {
				httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
					Message: fmt.Sprintf("Record at \"after_id\" (%q) does not exist.", paginationParams.AfterID.String()),
				})
				return err
			} else if err != nil {
				httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
					Message: "Internal error fetching workspace build at \"after_id\".",
					Detail:  err.Error(),
				})
				return err
			}
		}

		req := database.GetWorkspaceBuildsByWorkspaceIDParams{
			WorkspaceID: workspace.ID,
			AfterID:     paginationParams.AfterID,
			// #nosec G115 - Pagination offsets are small and fit in int32
			OffsetOpt: int32(paginationParams.Offset),
			// #nosec G115 - Pagination limits are small and fit in int32
			LimitOpt: int32(paginationParams.Limit),
			Since:    dbtime.Time(since),
		}
		workspaceBuilds, err = store.GetWorkspaceBuildsByWorkspaceID(ctx, req)
		if xerrors.Is(err, sql.ErrNoRows) {
			err = nil
		}
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Internal error fetching workspace build.",
				Detail:  err.Error(),
			})
			return err
		}

		return nil
	}, nil)
	if err != nil {
		return
	}

	data, err := api.workspaceBuildsData(ctx, workspaceBuilds, wsrelated.AllLatestBuild())
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error getting workspace build data.",
			Detail:  err.Error(),
		})
		return
	}

	apiBuilds, err := api.convertWorkspaceBuilds(
		wsrelated.AllLatestBuild(),
		workspaceBuilds,
		[]database.Workspace{workspace},
		data.jobs,
		data.resources,
		data.metadata,
		data.agents,
		data.apps,
		data.appStatuses,
		data.scripts,
		data.logSources,
		data.templateVersions,
		data.provisionerDaemons,
	)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace build.",
			Detail:  err.Error(),
		})
		return
	}

	httpapi.Write(ctx, rw, http.StatusOK, apiBuilds)
}

// @Summary Get workspace build by user, workspace name, and build number
// @ID get-workspace-build-by-user-workspace-name-and-build-number
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param user path string true "User ID, name, or me"
// @Param workspacename path string true "Workspace name"
// @Param buildnumber path string true "Build number" format(number)
// @Success 200 {object} codersdk.WorkspaceBuild
// @Router /api/v2/users/{user}/workspace/{workspacename}/builds/{buildnumber} [get]
func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	mems := httpmw.OrganizationMembersParam(r)
	workspaceName := chi.URLParam(r, "workspacename")
	buildNumber, err := strconv.ParseInt(chi.URLParam(r, "buildnumber"), 10, 32)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Failed to parse build number as integer.",
			Detail:  err.Error(),
		})
		return
	}

	workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
		OwnerID: mems.UserID(),
		Name:    workspaceName,
	})
	if httpapi.Is404Error(err) {
		httpapi.ResourceNotFound(rw)
		return
	}
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace by name.",
			Detail:  err.Error(),
		})
		return
	}

	workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
		WorkspaceID: workspace.ID,
		BuildNumber: int32(buildNumber),
	})
	if httpapi.Is404Error(err) {
		httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
			Message: fmt.Sprintf("Workspace %q Build %d does not exist.", workspaceName, buildNumber),
		})
		return
	}
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace build.",
			Detail:  err.Error(),
		})
		return
	}

	data, err := api.workspaceBuildsData(ctx, []database.WorkspaceBuild{workspaceBuild}, wsrelated.AllLatestBuild())
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error getting workspace build data.",
			Detail:  err.Error(),
		})
		return
	}

	apiBuild, err := api.convertWorkspaceBuild(
		workspaceBuild,
		workspace,
		data.jobs[0],
		newWorkspaceBuildIndex(
			data.resources,
			data.metadata,
			data.agents,
			data.apps,
			data.appStatuses,
			data.scripts,
			data.logSources,
			data.provisionerDaemons,
		),
		data.templateVersions[0],
	)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace build.",
			Detail:  err.Error(),
		})
		return
	}

	httpapi.Write(ctx, rw, http.StatusOK, apiBuild)
}

// Azure supports instance identity verification:
// https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#tabgroup_14
//
// @Summary Create workspace build
// @ID create-workspace-build
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Builds
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.CreateWorkspaceBuildRequest true "Create workspace build request"
// @Success 201 {object} codersdk.WorkspaceBuild
// @Router /api/v2/workspaces/{workspace}/builds [post]
func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)
	workspace := httpmw.WorkspaceParam(r)
	var createBuild codersdk.CreateWorkspaceBuildRequest
	if !httpapi.Read(ctx, rw, r, &createBuild) {
		return
	}

	// We want to allow a delete build for a deleted workspace, but not a start or stop build.
	if workspace.Deleted && createBuild.Transition != codersdk.WorkspaceTransitionDelete {
		httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
			Message: fmt.Sprintf("Cannot %s a deleted workspace!", createBuild.Transition),
			Detail:  "This workspace has been deleted and cannot be modified.",
		})
		return
	}

	apiBuild, err := api.postWorkspaceBuildsInternal(
		ctx,
		apiKey,
		workspace,
		createBuild,
		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
	}

	httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
}

// postWorkspaceBuildsInternal handles the internal logic for creating
// workspace builds, can be called by other handlers and must not
// reference httpmw.
func (api *API) postWorkspaceBuildsInternal(
	ctx context.Context,
	apiKey database.APIKey,
	workspace database.Workspace,
	createBuild codersdk.CreateWorkspaceBuildRequest,
	authorize func(action policy.Action, object rbac.Objecter) bool,
	workspaceBuildBaggage audit.WorkspaceBuildBaggage,
) (
	codersdk.WorkspaceBuild,
	error,
) {
	if err := validateCreateWorkspaceBuildOnSuccess(createBuild); err != nil {
		return codersdk.WorkspaceBuild{}, err
	}
	if err := api.validateWorkspaceSecretInputs(createBuild.Secrets); err != nil {
		return codersdk.WorkspaceBuild{}, err
	}

	var childParameterValuesJSON json.RawMessage
	if createBuild.OnSuccess != nil {
		childParameterValues := createBuild.OnSuccess.RichParameterValues
		if childParameterValues == nil {
			childParameterValues = []codersdk.WorkspaceBuildParameter{}
		}

		var err error
		childParameterValuesJSON, err = json.Marshal(childParameterValues)
		if err != nil {
			return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
				http.StatusInternalServerError,
				codersdk.Response{
					Message: "Internal error preparing follow-up workspace build parameters",
					Detail:  err.Error(),
				},
			)
		}
	}

	transition := database.WorkspaceTransition(createBuild.Transition)
	builder := wsbuilder.New(workspace, transition, *api.BuildUsageChecker.Load()).
		Initiator(apiKey.UserID).
		RichParameterValues(createBuild.RichParameterValues).
		Secrets(createBuild.Secrets).
		LogLevel(string(createBuild.LogLevel)).
		DeploymentValues(api.Options.DeploymentValues).
		Experiments(api.Experiments).
		TemplateVersionPresetID(createBuild.TemplateVersionPresetID).
		BuildMetrics(api.WorkspaceBuilderMetrics)

	if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
		builder = builder.Reason(database.BuildReason(createBuild.Reason))
	}

	var (
		previousWorkspaceBuild database.WorkspaceBuild
		workspaceBuild         *database.WorkspaceBuild
		provisionerJob         *database.ProvisionerJob
		provisionerDaemons     []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
	)

	err := api.Database.InTx(func(tx database.Store) error {
		var err error

		// #20925: if the workspace is dormant and we are starting the workspace,
		// we need to unset that status before inserting a new build.
		// This is done inside the transaction for consistency, but it could also be
		// done outside the transaction so that an attempt to start a workspace will
		// also unset dormancy.
		if workspace.DormantAt.Valid && transition == database.WorkspaceTransitionStart {
			if _, err := tx.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
				ID:        workspace.ID,
				DormantAt: sql.NullTime{Valid: false},
			}); err != nil {
				return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
					Message: "Internal error unsetting workspace dormant status",
					Detail:  err.Error(),
				})
			}
			// We need to audit this change separately.
			updatedWorkspace := workspace.WorkspaceTable()
			updatedWorkspace.DormantAt = sql.NullTime{Valid: false}
			auditor := api.Auditor.Load()
			bag := audit.BaggageFromContext(ctx)
			audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceTable]{
				Audit:          *auditor,
				Old:            workspace.WorkspaceTable(),
				New:            updatedWorkspace,
				Log:            api.Logger,
				UserID:         apiKey.UserID,
				OrganizationID: workspace.OrganizationID,
				RequestID:      workspace.ID,
				IP:             bag.IP,
				Action:         database.AuditActionWrite,
				Status:         http.StatusOK,
			})
		}

		previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
		if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
			api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err))
			return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
				Message: "Internal error fetching previous workspace build",
				Detail:  err.Error(),
			})
		}

		if createBuild.TemplateVersionID != uuid.Nil {
			builder = builder.VersionID(createBuild.TemplateVersionID)
		}

		if createBuild.Orphan {
			if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
				return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
					Message: "Orphan is only permitted when deleting a workspace.",
				})
			}
			if len(createBuild.ProvisionerState) > 0 {
				return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
					Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
				})
			}
			builder = builder.Orphan()
		}
		if len(createBuild.ProvisionerState) > 0 {
			builder = builder.State(createBuild.ProvisionerState)
		}

		workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
			ctx,
			tx,
			api.FileCache,
			func(action policy.Action, object rbac.Objecter) bool {
				if auth := authorize(action, object); auth {
					return true
				}
				// Special handling for prebuilt workspace deletion
				if action == policy.ActionDelete {
					if workspaceObj, ok := object.(database.PrebuiltWorkspaceResource); ok && workspaceObj.IsPrebuild() {
						return authorize(action, workspaceObj.AsPrebuild())
					}
				}
				return false
			},
			workspaceBuildBaggage,
		)
		if err != nil {
			return err
		}

		if createBuild.OnSuccess != nil {
			onSuccessReq := createBuild.OnSuccess
			// Reuse the parent build's timestamps so the orchestration row and
			// the parent build agree on created_at.
			_, err = tx.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
				ID:              uuid.New(),
				CreatedAt:       workspaceBuild.CreatedAt,
				UpdatedAt:       workspaceBuild.UpdatedAt,
				ParentBuildID:   workspaceBuild.ID,
				ChildTransition: database.WorkspaceTransition(onSuccessReq.Transition),
				ChildTemplateVersionID: uuid.NullUUID{
					UUID:  onSuccessReq.TemplateVersionID,
					Valid: onSuccessReq.TemplateVersionID != uuid.Nil,
				},
				ChildTemplateVersionPresetID: uuid.NullUUID{
					UUID:  onSuccessReq.TemplateVersionPresetID,
					Valid: onSuccessReq.TemplateVersionPresetID != uuid.Nil,
				},
				ChildRichParameterValues: childParameterValuesJSON,
				ChildLogLevel:            string(createBuild.LogLevel),
				ChildReason: database.NullBuildReason{
					BuildReason: database.BuildReason(createBuild.Reason),
					Valid:       createBuild.Reason != "",
				},
			})
			if err != nil {
				if dbauthz.IsNotAuthorizedError(err) {
					detail := "Queuing the follow-up workspace build requires permission to start the workspace."
					if onSuccessReq.TemplateVersionID != uuid.Nil {
						detail = "Pinning a template version on the follow-up build requires template update permission. Omit template_version_id to use the active version."
					}
					return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
						Message: "Unauthorized to queue follow-up workspace build.",
						Detail:  detail,
					})
				}
				api.Logger.Error(ctx, "failed to queue follow-up workspace build",
					slog.F("workspace_id", workspace.ID),
					slog.F("workspace_build_id", workspaceBuild.ID),
					slog.Error(err),
				)
				return httperror.NewResponseError(
					http.StatusInternalServerError,
					codersdk.Response{
						Message: "Internal error queueing follow-up workspace build",
						Detail:  err.Error(),
					},
				)
			}
		}

		return nil
	}, nil)
	if err != nil {
		return codersdk.WorkspaceBuild{}, err
	}

	var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow
	if provisionerJob != nil {
		queuePos.ProvisionerJob = *provisionerJob
		queuePos.QueuePosition = 0
		if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil {
			// Client probably doesn't care about this error, so just log it.
			api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err))
		}

		// We may need to complete the audit if wsbuilder determined that
		// no provisioner could handle an orphan-delete job and completed it.
		if createBuild.Orphan && createBuild.Transition == codersdk.WorkspaceTransitionDelete && provisionerJob.CompletedAt.Valid {
			api.Logger.Warn(ctx, "orphan delete handled by wsbuilder due to no eligible provisioners",
				slog.F("workspace_id", workspace.ID),
				slog.F("workspace_build_id", workspaceBuild.ID),
				slog.F("provisioner_job_id", provisionerJob.ID),
			)
			buildResourceInfo, err := audit.WorkspaceBuildFields(ctx, api.Database, workspace, *workspaceBuild)
			if err != nil {
				api.Logger.Error(ctx, "failed to list workspace build secrets for audit", slog.Error(err))
			}
			buildResourceInfo.WorkspaceOwner = workspace.OwnerName
			briBytes, err := json.Marshal(buildResourceInfo)
			if err != nil {
				api.Logger.Error(ctx, "failed to marshal build resource info for audit", slog.Error(err))
			}
			auditor := api.Auditor.Load()
			bag := audit.BaggageFromContext(ctx)
			audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceBuild]{
				Audit:            *auditor,
				Log:              api.Logger,
				UserID:           provisionerJob.InitiatorID,
				OrganizationID:   workspace.OrganizationID,
				RequestID:        provisionerJob.ID,
				IP:               bag.IP,
				Action:           database.AuditActionDelete,
				Old:              previousWorkspaceBuild,
				New:              *workspaceBuild,
				Status:           http.StatusOK,
				AdditionalFields: briBytes,
			})
		}
	}

	apiBuild, err := api.convertWorkspaceBuild(
		*workspaceBuild,
		workspace,
		queuePos,
		newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons),
		database.TemplateVersion{},
	)
	if err != nil {
		return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
			http.StatusInternalServerError,
			codersdk.Response{
				Message: "Internal error converting workspace build.",
				Detail:  err.Error(),
			},
		)
	}

	// If this workspace build has a different template version ID to the previous build
	// we can assume it has just been updated.
	if createBuild.TemplateVersionID != uuid.Nil && createBuild.TemplateVersionID != previousWorkspaceBuild.TemplateVersionID {
		// nolint:gocritic // Need system context to fetch admins
		admins, err := findTemplateAdmins(dbauthz.AsSystemRestricted(ctx), api.Database)
		if err != nil {
			api.Logger.Error(ctx, "find template admins", slog.Error(err))
		} else {
			for _, admin := range admins {
				// Don't send notifications to user which initiated the event.
				if admin.ID == apiKey.UserID {
					continue
				}

				api.notifyWorkspaceUpdated(ctx, apiKey.UserID, admin.ID, workspace, createBuild.RichParameterValues)
			}
		}
	}

	api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
		Kind:        wspubsub.WorkspaceEventKindStateChange,
		WorkspaceID: workspace.ID,
	})

	return apiBuild, nil
}

// validateCreateWorkspaceBuildOnSuccess enforces the subset of build options
// that currently has well-defined stop-then-start semantics.
func validateCreateWorkspaceBuildOnSuccess(createBuild codersdk.CreateWorkspaceBuildRequest) error {
	onSuccess := createBuild.OnSuccess
	if onSuccess == nil {
		return nil
	}

	if createBuild.Transition != codersdk.WorkspaceTransitionStop {
		return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: "OnSuccess is only permitted when stopping a workspace.",
		})
	}
	if onSuccess.Transition != codersdk.WorkspaceTransitionStart {
		return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: "OnSuccess transition must be start.",
		})
	}
	if createBuild.DryRun {
		return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: "OnSuccess cannot be set alongside DryRun.",
		})
	}
	if createBuild.Orphan {
		return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: "OnSuccess cannot be set alongside Orphan.",
		})
	}
	if len(createBuild.ProvisionerState) > 0 {
		return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: "OnSuccess cannot be set alongside ProvisionerState.",
		})
	}
	if onSuccess.TemplateVersionPresetID != uuid.Nil && onSuccess.TemplateVersionID == uuid.Nil {
		return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: "OnSuccess TemplateVersionPresetID requires TemplateVersionID.",
		})
	}

	return nil
}

func (api *API) notifyWorkspaceUpdated(
	ctx context.Context,
	initiatorID uuid.UUID,
	receiverID uuid.UUID,
	workspace database.Workspace,
	parameters []codersdk.WorkspaceBuildParameter,
) {
	log := api.Logger.With(slog.F("workspace_id", workspace.ID))

	template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
	if err != nil {
		log.Warn(ctx, "failed to fetch template for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
		return
	}

	version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
	if err != nil {
		log.Warn(ctx, "failed to fetch template version for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
		return
	}

	initiator, err := api.Database.GetUserByID(ctx, initiatorID)
	if err != nil {
		log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("initiator_id", initiatorID), slog.Error(err))
		return
	}

	owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
	if err != nil {
		log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("owner_id", workspace.OwnerID), slog.Error(err))
		return
	}

	buildParameters := make([]map[string]any, len(parameters))
	for idx, parameter := range parameters {
		buildParameters[idx] = map[string]any{
			"name":  parameter.Name,
			"value": parameter.Value,
		}
	}

	if _, err := api.NotificationsEnqueuer.EnqueueWithData(
		// nolint:gocritic // Need notifier actor to enqueue notifications
		dbauthz.AsNotifier(ctx),
		receiverID,
		notifications.TemplateWorkspaceManuallyUpdated,
		map[string]string{
			"organization":             template.OrganizationName,
			"initiator":                initiator.Name,
			"workspace":                workspace.Name,
			"template":                 template.Name,
			"version":                  version.Name,
			"workspace_owner_username": owner.Username,
		},
		map[string]any{
			"workspace":        map[string]any{"id": workspace.ID, "name": workspace.Name},
			"template":         map[string]any{"id": template.ID, "name": template.Name},
			"template_version": map[string]any{"id": version.ID, "name": version.Name},
			"owner":            map[string]any{"id": owner.ID, "name": owner.Name, "email": owner.Email},
			"parameters":       buildParameters,
		},
		"api-workspaces-updated",
		// Associate this notification with all the related entities
		workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
	); err != nil {
		log.Warn(ctx, "failed to notify of workspace update", slog.Error(err))
	}
}

// @Summary Report a workspace build debug click
// @ID report-a-workspace-build-debug-click
// @Security CoderSessionToken
// @Accept json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Param request body codersdk.WorkspaceBuildDebugEventRequest true "Debug event"
// @Success 204
// @Router /api/v2/workspacebuilds/{workspacebuild}/debug-events [post]
// @x-apidocgen {"skip": true}
func (api *API) postWorkspaceBuildDebugEvent(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)
	workspaceBuild := httpmw.WorkspaceBuildParam(r)

	if !api.Experiments.Enabled(codersdk.ExperimentEnableAIWorkspaceDebug) {
		httpapi.ResourceNotFound(rw)
		return
	}
	if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String()).AnyOrganization()) {
		httpapi.Forbidden(rw)
		return
	}

	job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching provisioner job.",
			Detail:  err.Error(),
		})
		return
	}
	if job.JobStatus != database.ProvisionerJobStatusFailed {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Only failed workspace builds can be debugged.",
		})
		return
	}

	var req codersdk.WorkspaceBuildDebugEventRequest
	if !httpapi.Read(ctx, rw, r, &req) {
		return
	}

	api.Telemetry.Report(&telemetry.Snapshot{
		WorkspaceBuildDebugEvents: []telemetry.WorkspaceBuildDebugEvent{
			{
				ID:               req.ID,
				EventType:        telemetry.WorkspaceBuildDebugEventClick,
				UserID:           apiKey.UserID,
				WorkspaceID:      workspaceBuild.WorkspaceID,
				WorkspaceBuildID: workspaceBuild.ID,
				Transition:       string(workspaceBuild.Transition),
				Reason:           string(workspaceBuild.Reason),
				CreatedAt:        dbtime.Now(),
			},
		},
	})

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Cancel workspace build
// @ID cancel-workspace-build
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Param expect_status query string false "Expected status of the job. If expect_status is supplied, the request will be rejected with 412 Precondition Failed if the job doesn't match the state when performing the cancellation." Enums(running, pending)
// @Success 200 {object} codersdk.Response
// @Router /api/v2/workspacebuilds/{workspacebuild}/cancel [patch]
func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	var expectStatus database.ProvisionerJobStatus
	expectStatusParam := r.URL.Query().Get("expect_status")
	if expectStatusParam != "" {
		if expectStatusParam != "running" && expectStatusParam != "pending" {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: fmt.Sprintf("Invalid expect_status %q. Only 'running' or 'pending' are allowed.", expectStatusParam),
			})
			return
		}
		expectStatus = database.ProvisionerJobStatus(expectStatusParam)
	}

	workspaceBuild := httpmw.WorkspaceBuildParam(r)
	workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "No workspace exists for this job.",
		})
		return
	}

	code := http.StatusInternalServerError
	resp := codersdk.Response{
		Message: "Internal error canceling workspace build.",
	}
	err = api.Database.InTx(func(db database.Store) error {
		valid, err := verifyUserCanCancelWorkspaceBuilds(ctx, db, httpmw.APIKey(r).UserID, workspace.TemplateID, expectStatus)
		if err != nil {
			code = http.StatusInternalServerError
			resp.Message = "Internal error verifying permission to cancel workspace build."
			resp.Detail = err.Error()

			return xerrors.Errorf("verify user can cancel workspace builds: %w", err)
		}
		if !valid {
			code = http.StatusForbidden
			resp.Message = "User is not allowed to cancel workspace builds. Owner role is required."

			return xerrors.New("user is not allowed to cancel workspace builds")
		}

		job, err := db.GetProvisionerJobByIDWithLock(ctx, workspaceBuild.JobID)
		if err != nil {
			code = http.StatusInternalServerError
			resp.Message = "Internal error fetching provisioner job."
			resp.Detail = err.Error()

			return xerrors.Errorf("get provisioner job: %w", err)
		}
		if job.CompletedAt.Valid {
			code = http.StatusBadRequest
			resp.Message = "Job has already completed!"

			return xerrors.New("job has already completed")
		}
		if job.CanceledAt.Valid {
			code = http.StatusBadRequest
			resp.Message = "Job has already been marked as canceled!"

			return xerrors.New("job has already been marked as canceled")
		}

		if expectStatus != "" && job.JobStatus != expectStatus {
			code = http.StatusPreconditionFailed
			resp.Message = "Job is not in the expected state."

			return xerrors.Errorf("job is not in the expected state: expected: %q, got %q", expectStatus, job.JobStatus)
		}

		err = db.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{
			ID: job.ID,
			CanceledAt: sql.NullTime{
				Time:  dbtime.Now(),
				Valid: true,
			},
			CompletedAt: sql.NullTime{
				Time: dbtime.Now(),
				// If the job is running, don't mark it completed!
				Valid: !job.WorkerID.Valid,
			},
		})
		if err != nil {
			code = http.StatusInternalServerError
			resp.Message = "Internal error updating provisioner job."
			resp.Detail = err.Error()

			return xerrors.Errorf("update provisioner job: %w", err)
		}

		return nil
	}, nil)
	if err != nil {
		httpapi.Write(ctx, rw, code, resp)
		return
	}

	api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
		Kind:        wspubsub.WorkspaceEventKindStateChange,
		WorkspaceID: workspace.ID,
	})
	err = wspubsub.PublishWorkspaceBuildOrchestrationWake(ctx, api.Pubsub)
	if err != nil {
		api.Logger.Warn(ctx, "failed to publish workspace build orchestration wake", slog.Error(err))
	}

	// Publish workspace build update to the all builds channel if the experiment is enabled.
	if api.Experiments.Enabled(codersdk.ExperimentWorkspaceBuildUpdates) {
		err = wspubsub.PublishWorkspaceBuildUpdate(ctx, api.Pubsub, codersdk.WorkspaceBuildUpdate{
			WorkspaceID:   workspace.ID,
			WorkspaceName: workspace.Name,
			BuildID:       workspaceBuild.ID,
			Transition:    string(workspaceBuild.Transition),
			JobStatus:     string(database.ProvisionerJobStatusCanceled),
			BuildNumber:   workspaceBuild.BuildNumber,
		})
		if err != nil {
			api.Logger.Warn(ctx, "failed to publish workspace build update", slog.Error(err))
		}
	}

	httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
		Message: "Job has been marked as canceled...",
	})
}

func verifyUserCanCancelWorkspaceBuilds(ctx context.Context, store database.Store, userID uuid.UUID, templateID uuid.UUID, jobStatus database.ProvisionerJobStatus) (bool, error) {
	// If the jobStatus is pending, we always allow cancellation regardless of
	// the template setting as it's non-destructive to Terraform resources.
	if jobStatus == database.ProvisionerJobStatusPending {
		return true, nil
	}

	template, err := store.GetTemplateByID(ctx, templateID)
	if err != nil {
		return false, xerrors.New("no template exists for this workspace")
	}

	if template.AllowUserCancelWorkspaceJobs {
		return true, nil // all users can cancel workspace builds
	}

	user, err := store.GetUserByID(ctx, userID)
	if err != nil {
		return false, xerrors.New("user does not exist")
	}
	return slices.Contains(user.RBACRoles, rbac.RoleOwner().String()), nil // only user with "owner" role can cancel workspace builds
}

// @Summary Get build parameters for workspace build
// @ID get-build-parameters-for-workspace-build
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Success 200 {array} codersdk.WorkspaceBuildParameter
// @Router /api/v2/workspacebuilds/{workspacebuild}/parameters [get]
func (api *API) workspaceBuildParameters(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspaceBuild := httpmw.WorkspaceBuildParam(r)

	parameters, err := api.Database.GetWorkspaceBuildParameters(ctx, workspaceBuild.ID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace build parameters.",
			Detail:  err.Error(),
		})
		return
	}
	apiParameters := db2sdk.WorkspaceBuildParameters(parameters)
	httpapi.Write(ctx, rw, http.StatusOK, apiParameters)
}

// @Summary Get workspace build logs
// @ID get-workspace-build-logs
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Param before query int false "Before log id"
// @Param after query int false "After log id"
// @Param follow query bool false "Follow log stream"
// @Param format query string false "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true." Enums(json,text)
// @Success 200 {array} codersdk.ProvisionerJobLog
// @Router /api/v2/workspacebuilds/{workspacebuild}/logs [get]
func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspaceBuild := httpmw.WorkspaceBuildParam(r)

	job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching provisioner job.",
			Detail:  err.Error(),
		})
		return
	}
	api.provisionerJobLogs(rw, r, job)
}

// @Summary Get provisioner state for workspace build
// @ID get-provisioner-state-for-workspace-build
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Success 200 {object} codersdk.WorkspaceBuild
// @Router /api/v2/workspacebuilds/{workspacebuild}/state [get]
func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspaceBuild := httpmw.WorkspaceBuildParam(r)

	// The dbauthz layer enforces policy.ActionUpdate on the template.
	row, err := api.Database.GetWorkspaceBuildProvisionerStateByID(ctx, workspaceBuild.ID)
	if httpapi.Is404Error(err) {
		httpapi.ResourceNotFound(rw)
		return
	}
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching provisioner state.",
			Detail:  err.Error(),
		})
		return
	}

	rw.Header().Set("Content-Type", "application/json")
	rw.WriteHeader(http.StatusOK)
	_, _ = rw.Write(row.ProvisionerState)
}

// @Summary Update workspace build state
// @ID update-workspace-build-state
// @Security CoderSessionToken
// @Accept json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceBuildStateRequest true "Request body"
// @Success 204
// @Router /api/v2/workspacebuilds/{workspacebuild}/state [put]
func (api *API) workspaceBuildUpdateState(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspaceBuild := httpmw.WorkspaceBuildParam(r)
	workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "No workspace exists for this job.",
		})
		return
	}
	template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to get template",
			Detail:  err.Error(),
		})
		return
	}

	// You must have update permissions on the template to update the state.
	if !api.Authorize(r, policy.ActionUpdate, template.RBACObject()) {
		httpapi.ResourceNotFound(rw)
		return
	}

	var req codersdk.UpdateWorkspaceBuildStateRequest
	if !httpapi.Read(ctx, rw, r, &req) {
		return
	}

	// Use system context since we've already verified authorization via template permissions.
	// nolint:gocritic // System access required for provisioner state update.
	err = api.Database.UpdateWorkspaceBuildProvisionerStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildProvisionerStateByIDParams{
		ID:               workspaceBuild.ID,
		ProvisionerState: req.State,
		UpdatedAt:        dbtime.Now(),
	})
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to update workspace build state.",
			Detail:  err.Error(),
		})
		return
	}

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Get workspace build timings by ID
// @ID get-workspace-build-timings-by-id
// @Security CoderSessionToken
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID" format(uuid)
// @Success 200 {object} codersdk.WorkspaceBuildTimings
// @Router /api/v2/workspacebuilds/{workspacebuild}/timings [get]
func (api *API) workspaceBuildTimings(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx   = r.Context()
		build = httpmw.WorkspaceBuildParam(r)
	)

	timings, err := api.buildTimings(ctx, build)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching timings.",
			Detail:  err.Error(),
		})
		return
	}

	httpapi.Write(ctx, rw, http.StatusOK, timings)
}

type workspaceBuildsData struct {
	jobs               []database.GetProvisionerJobsByIDsWithQueuePositionRow
	templateVersions   []database.TemplateVersion
	resources          []database.WorkspaceResource
	metadata           []database.WorkspaceResourceMetadatum
	agents             []database.WorkspaceAgent
	apps               []database.WorkspaceApp
	appStatuses        []database.WorkspaceAppStatus
	scripts            []database.GetWorkspaceAgentScriptsByAgentIDsRow
	logSources         []database.WorkspaceAgentLogSource
	provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
}

// provisionerJobsByIDs fetches provisioner jobs by ID, shaped as
// GetProvisionerJobsByIDsWithQueuePositionRow so callers can treat the result
// uniformly. When the selection requests the queue position it uses
// GetProvisionerJobsByIDsWithQueuePosition, whose queue position and size are
// computed with window functions over pending jobs and provisioner daemons and
// are comparatively expensive. Otherwise, it uses the cheaper
// GetProvisionerJobsByIDs and leaves QueuePosition and QueueSize zero.
func (api *API) provisionerJobsByIDs(ctx context.Context, jobIDs []uuid.UUID, cfg wsrelated.Job) ([]database.GetProvisionerJobsByIDsWithQueuePositionRow, error) {
	if cfg.QueuePosition {
		return api.Database.GetProvisionerJobsByIDsWithQueuePosition(ctx, database.GetProvisionerJobsByIDsWithQueuePositionParams{
			IDs:             jobIDs,
			StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(),
		})
	}

	provisionerJobs, err := api.Database.GetProvisionerJobsByIDs(ctx, jobIDs)
	if err != nil {
		return nil, err
	}
	jobs := make([]database.GetProvisionerJobsByIDsWithQueuePositionRow, 0, len(provisionerJobs))
	for _, job := range provisionerJobs {
		jobs = append(jobs, database.GetProvisionerJobsByIDsWithQueuePositionRow{
			ID:             job.ID,
			CreatedAt:      job.CreatedAt,
			ProvisionerJob: job,
		})
	}
	return jobs, nil
}

func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild, cfg wsrelated.LatestBuild) (workspaceBuildsData, error) {
	jobIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
	for _, build := range workspaceBuilds {
		jobIDs = append(jobIDs, build.JobID)
	}

	var (
		jobs                   []database.GetProvisionerJobsByIDsWithQueuePositionRow
		pendingJobProvisioners []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
	)
	if cfg.Job != nil {
		var err error
		jobs, err = api.provisionerJobsByIDs(ctx, jobIDs, *cfg.Job)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			return workspaceBuildsData{}, xerrors.Errorf("get provisioner jobs: %w", err)
		}
		pendingJobIDs := []uuid.UUID{}
		for _, job := range jobs {
			if job.ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending {
				pendingJobIDs = append(pendingJobIDs, job.ProvisionerJob.ID)
			}
		}

		pendingJobProvisioners, err = api.Database.GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx, pendingJobIDs)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			return workspaceBuildsData{}, xerrors.Errorf("get provisioner daemons: %w", err)
		}
	}

	var templateVersions []database.TemplateVersion
	if cfg.TemplateVersion {
		templateVersionIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
		for _, build := range workspaceBuilds {
			templateVersionIDs = append(templateVersionIDs, build.TemplateVersionID)
		}

		var err error
		// nolint:gocritic // Getting template versions by ID is a system function.
		templateVersions, err = api.Database.GetTemplateVersionsByIDs(dbauthz.AsSystemRestricted(ctx), templateVersionIDs)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			return workspaceBuildsData{}, xerrors.Errorf("get template versions: %w", err)
		}
	}

	if cfg.Resources == nil {
		return workspaceBuildsData{
			jobs:               jobs,
			templateVersions:   templateVersions,
			provisionerDaemons: pendingJobProvisioners,
		}, nil
	}

	// nolint:gocritic // Getting workspace resources by job ID is a system function.
	resources, err := api.Database.GetWorkspaceResourcesByJobIDs(dbauthz.AsSystemRestricted(ctx), jobIDs)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return workspaceBuildsData{}, xerrors.Errorf("get workspace resources by job: %w", err)
	}

	if len(resources) == 0 {
		return workspaceBuildsData{
			jobs:               jobs,
			templateVersions:   templateVersions,
			provisionerDaemons: pendingJobProvisioners,
		}, nil
	}

	resourceIDs := make([]uuid.UUID, 0)
	for _, resource := range resources {
		resourceIDs = append(resourceIDs, resource.ID)
	}

	var (
		metadata []database.WorkspaceResourceMetadatum
		agents   []database.WorkspaceAgent
		egRes    errgroup.Group
	)
	if cfg.Resources.Metadata {
		egRes.Go(func() (err error) {
			// nolint:gocritic // Getting workspace resource metadata by resource ID is a system function.
			metadata, err = api.Database.GetWorkspaceResourceMetadataByResourceIDs(dbauthz.AsSystemRestricted(ctx), resourceIDs)
			if err != nil && !errors.Is(err, sql.ErrNoRows) {
				return xerrors.Errorf("fetching resource metadata: %w", err)
			}
			return nil
		})
	}
	if cfg.Resources.Agents != nil {
		egRes.Go(func() (err error) {
			// nolint:gocritic // Getting workspace agents by resource IDs is a system function.
			agents, err = api.Database.GetWorkspaceAgentsByResourceIDs(dbauthz.AsSystemRestricted(ctx), resourceIDs)
			if err != nil && !errors.Is(err, sql.ErrNoRows) {
				return xerrors.Errorf("get workspace agents: %w", err)
			}
			return nil
		})
	}
	if err := egRes.Wait(); err != nil {
		return workspaceBuildsData{}, err
	}

	if cfg.Resources.Agents == nil || len(agents) == 0 {
		return workspaceBuildsData{
			jobs:               jobs,
			templateVersions:   templateVersions,
			resources:          resources,
			metadata:           metadata,
			provisionerDaemons: pendingJobProvisioners,
		}, nil
	}
	agentsCfg := cfg.Resources.Agents

	agentIDs := make([]uuid.UUID, 0)
	for _, agent := range agents {
		agentIDs = append(agentIDs, agent.ID)
	}

	var (
		apps       []database.WorkspaceApp
		scripts    []database.GetWorkspaceAgentScriptsByAgentIDsRow
		logSources []database.WorkspaceAgentLogSource
	)

	var eg errgroup.Group
	if agentsCfg.Apps != nil {
		eg.Go(func() (err error) {
			// nolint:gocritic // Getting workspace apps by agent IDs is a system function.
			apps, err = api.Database.GetWorkspaceAppsByAgentIDs(dbauthz.AsSystemRestricted(ctx), agentIDs)
			return err
		})
	}
	if agentsCfg.Scripts {
		eg.Go(func() (err error) {
			// nolint:gocritic // Getting workspace scripts by agent IDs is a system function.
			scripts, err = api.Database.GetWorkspaceAgentScriptsByAgentIDs(dbauthz.AsSystemRestricted(ctx), agentIDs)
			return err
		})
	}
	if agentsCfg.LogSources {
		eg.Go(func() error {
			var err error
			// nolint:gocritic // Getting workspace agent log sources by agent IDs is a system function.
			logSources, err = api.Database.GetWorkspaceAgentLogSourcesByAgentIDs(dbauthz.AsSystemRestricted(ctx), agentIDs)
			return err
		})
	}
	if err := eg.Wait(); err != nil {
		return workspaceBuildsData{}, err
	}

	var statuses []database.WorkspaceAppStatus
	if cfg.AppStatuses() {
		appIDs := make([]uuid.UUID, 0)
		for _, app := range apps {
			appIDs = append(appIDs, app.ID)
		}

		// nolint:gocritic // Getting workspace app statuses by app IDs is a system function.
		statuses, err = api.Database.GetWorkspaceAppStatusesByAppIDs(dbauthz.AsSystemRestricted(ctx), appIDs)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			return workspaceBuildsData{}, xerrors.Errorf("get workspace app statuses: %w", err)
		}
	}

	return workspaceBuildsData{
		jobs:               jobs,
		templateVersions:   templateVersions,
		resources:          resources,
		metadata:           metadata,
		agents:             agents,
		apps:               apps,
		appStatuses:        statuses,
		scripts:            scripts,
		logSources:         logSources,
		provisionerDaemons: pendingJobProvisioners,
	}, nil
}

// workspaceBuildIndex groups build rows by the ID they are looked up with.
// Conversion reorders rows within a bucket in place, so an index must be read
// by a single goroutine.
type workspaceBuildIndex struct {
	resourcesByJobID     map[uuid.UUID][]database.WorkspaceResource
	metadataByResourceID map[uuid.UUID][]database.WorkspaceResourceMetadatum
	agentsByResourceID   map[uuid.UUID][]database.WorkspaceAgent
	appsByAgentID        map[uuid.UUID][]database.WorkspaceApp
	statusesByAgentID    map[uuid.UUID][]database.WorkspaceAppStatus
	scriptsByAgentID     map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow
	logSourcesByAgentID  map[uuid.UUID][]database.WorkspaceAgentLogSource
	daemonsByJobID       map[uuid.UUID][]database.ProvisionerDaemon
}

// newWorkspaceBuildIndex makes one pass over each slice, copying rows into
// per-ID buckets. The input slices are neither modified nor aliased.
func newWorkspaceBuildIndex(
	workspaceResources []database.WorkspaceResource,
	resourceMetadata []database.WorkspaceResourceMetadatum,
	resourceAgents []database.WorkspaceAgent,
	agentApps []database.WorkspaceApp,
	agentAppStatuses []database.WorkspaceAppStatus,
	agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow,
	agentLogSources []database.WorkspaceAgentLogSource,
	provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
) *workspaceBuildIndex {
	index := &workspaceBuildIndex{
		resourcesByJobID:     make(map[uuid.UUID][]database.WorkspaceResource),
		metadataByResourceID: make(map[uuid.UUID][]database.WorkspaceResourceMetadatum),
		agentsByResourceID:   make(map[uuid.UUID][]database.WorkspaceAgent),
		appsByAgentID:        make(map[uuid.UUID][]database.WorkspaceApp),
		statusesByAgentID:    make(map[uuid.UUID][]database.WorkspaceAppStatus),
		scriptsByAgentID:     make(map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow),
		logSourcesByAgentID:  make(map[uuid.UUID][]database.WorkspaceAgentLogSource),
		daemonsByJobID:       make(map[uuid.UUID][]database.ProvisionerDaemon),
	}
	for _, resource := range workspaceResources {
		index.resourcesByJobID[resource.JobID] = append(index.resourcesByJobID[resource.JobID], resource)
	}
	for _, metadata := range resourceMetadata {
		index.metadataByResourceID[metadata.WorkspaceResourceID] = append(index.metadataByResourceID[metadata.WorkspaceResourceID], metadata)
	}
	for _, agent := range resourceAgents {
		index.agentsByResourceID[agent.ResourceID] = append(index.agentsByResourceID[agent.ResourceID], agent)
	}
	for _, app := range agentApps {
		index.appsByAgentID[app.AgentID] = append(index.appsByAgentID[app.AgentID], app)
	}
	for _, status := range agentAppStatuses {
		index.statusesByAgentID[status.AgentID] = append(index.statusesByAgentID[status.AgentID], status)
	}
	for _, script := range agentScripts {
		index.scriptsByAgentID[script.WorkspaceAgentID] = append(index.scriptsByAgentID[script.WorkspaceAgentID], script)
	}
	for _, logSource := range agentLogSources {
		index.logSourcesByAgentID[logSource.WorkspaceAgentID] = append(index.logSourcesByAgentID[logSource.WorkspaceAgentID], logSource)
	}
	for _, daemon := range provisionerDaemons {
		index.daemonsByJobID[daemon.JobID] = append(index.daemonsByJobID[daemon.JobID], daemon.ProvisionerDaemon)
	}
	// Agents within a resource are ordered by display order, then name.
	for _, agents := range index.agentsByResourceID {
		slices.SortFunc(agents, func(a, b database.WorkspaceAgent) int {
			return cmp.Or(
				cmp.Compare(a.DisplayOrder, b.DisplayOrder),
				cmp.Compare(a.Name, b.Name),
			)
		})
	}
	return index
}

func (api *API) convertWorkspaceBuilds(
	cfg wsrelated.LatestBuild,
	workspaceBuilds []database.WorkspaceBuild,
	workspaces []database.Workspace,
	jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow,
	workspaceResources []database.WorkspaceResource,
	resourceMetadata []database.WorkspaceResourceMetadatum,
	resourceAgents []database.WorkspaceAgent,
	agentApps []database.WorkspaceApp,
	agentAppStatuses []database.WorkspaceAppStatus,
	agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow,
	agentLogSources []database.WorkspaceAgentLogSource,
	templateVersions []database.TemplateVersion,
	provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
) ([]codersdk.WorkspaceBuild, error) {
	workspaceByID := map[uuid.UUID]database.Workspace{}
	for _, workspace := range workspaces {
		workspaceByID[workspace.ID] = workspace
	}
	jobByID := map[uuid.UUID]database.GetProvisionerJobsByIDsWithQueuePositionRow{}
	for _, job := range jobs {
		jobByID[job.ProvisionerJob.ID] = job
	}
	templateVersionByID := map[uuid.UUID]database.TemplateVersion{}
	for _, templateVersion := range templateVersions {
		templateVersionByID[templateVersion.ID] = templateVersion
	}

	index := newWorkspaceBuildIndex(
		workspaceResources,
		resourceMetadata,
		resourceAgents,
		agentApps,
		agentAppStatuses,
		agentScripts,
		agentLogSources,
		provisionerDaemons,
	)

	// Should never be nil for API consistency
	apiBuilds := []codersdk.WorkspaceBuild{}
	for _, build := range workspaceBuilds {
		job, exists := jobByID[build.JobID]
		if !exists && cfg.Job != nil {
			return nil, xerrors.New("build job not found")
		}
		workspace, exists := workspaceByID[build.WorkspaceID]
		if !exists {
			return nil, xerrors.New("workspace not found")
		}
		templateVersion, exists := templateVersionByID[build.TemplateVersionID]
		if !exists && cfg.TemplateVersion {
			return nil, xerrors.New("template version not found")
		}

		apiBuild, err := api.convertWorkspaceBuild(
			build,
			workspace,
			job,
			index,
			templateVersion,
		)
		if err != nil {
			return nil, xerrors.Errorf("converting workspace build: %w", err)
		}

		apiBuilds = append(apiBuilds, apiBuild)
	}

	return apiBuilds, nil
}

func (api *API) convertWorkspaceBuild(
	build database.WorkspaceBuild,
	workspace database.Workspace,
	job database.GetProvisionerJobsByIDsWithQueuePositionRow,
	index *workspaceBuildIndex,
	templateVersion database.TemplateVersion,
) (codersdk.WorkspaceBuild, error) {
	matchedProvisioners := db2sdk.MatchedProvisioners(index.daemonsByJobID[build.JobID], job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval)

	resources := index.resourcesByJobID[build.JobID]
	apiResources := make([]codersdk.WorkspaceResource, 0)
	resourceAgentsMinOrder := map[uuid.UUID]int32{} // map[resource.ID]minOrder
	for _, resource := range resources {
		agents := index.agentsByResourceID[resource.ID]

		apiAgents := make([]codersdk.WorkspaceAgent, 0)
		resourceAgentsMinOrder[resource.ID] = math.MaxInt32

		for _, agent := range agents {
			resourceAgentsMinOrder[resource.ID] = min(resourceAgentsMinOrder[resource.ID], agent.DisplayOrder)

			apps := index.appsByAgentID[agent.ID]
			scripts := index.scriptsByAgentID[agent.ID]
			statuses := index.statusesByAgentID[agent.ID]
			logSources := index.logSourcesByAgentID[agent.ID]
			apiScripts, err := convertScripts(scripts)
			if err != nil {
				return codersdk.WorkspaceBuild{}, xerrors.Errorf("converting workspace agent scripts: %w", err)
			}
			apiAgent, err := db2sdk.WorkspaceAgent(
				api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, statuses, agent, workspace.OwnerUsername, workspace.WorkspaceTable()), apiScripts, convertLogSources(logSources), api.AgentInactiveDisconnectTimeout,
				api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
			)
			if err != nil {
				return codersdk.WorkspaceBuild{}, xerrors.Errorf("converting workspace agent: %w", err)
			}
			apiAgents = append(apiAgents, apiAgent)
		}
		metadata := append(make([]database.WorkspaceResourceMetadatum, 0), index.metadataByResourceID[resource.ID]...)
		apiResources = append(apiResources, convertWorkspaceResource(resource, apiAgents, metadata))
	}
	sort.Slice(apiResources, func(i, j int) bool {
		orderI := resourceAgentsMinOrder[apiResources[i].ID]
		orderJ := resourceAgentsMinOrder[apiResources[j].ID]
		if orderI != orderJ {
			return orderI < orderJ
		}
		return apiResources[i].Name < apiResources[j].Name
	})

	var presetID *uuid.UUID
	if build.TemplateVersionPresetID.Valid {
		presetID = &build.TemplateVersionPresetID.UUID
	}
	var hasExternalAgent *bool
	if build.HasExternalAgent.Valid {
		hasExternalAgent = &build.HasExternalAgent.Bool
	}

	apiJob := convertProvisionerJob(job)
	transition := codersdk.WorkspaceTransition(build.Transition)
	return codersdk.WorkspaceBuild{
		ID:                      build.ID,
		CreatedAt:               build.CreatedAt,
		UpdatedAt:               build.UpdatedAt,
		WorkspaceOwnerID:        workspace.OwnerID,
		WorkspaceOwnerName:      workspace.OwnerUsername,
		WorkspaceOwnerAvatarURL: workspace.OwnerAvatarUrl,
		WorkspaceID:             build.WorkspaceID,
		WorkspaceName:           workspace.Name,
		TemplateVersionID:       build.TemplateVersionID,
		TemplateVersionName:     templateVersion.Name,
		BuildNumber:             build.BuildNumber,
		Transition:              transition,
		InitiatorID:             build.InitiatorID,
		InitiatorUsername:       build.InitiatorByUsername,
		Job:                     apiJob,
		Deadline:                codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()),
		MaxDeadline:             codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()),
		Reason:                  codersdk.BuildReason(build.Reason),
		Resources:               apiResources,
		Status:                  codersdk.ConvertWorkspaceStatus(apiJob.Status, transition),
		DailyCost:               build.DailyCost,
		MatchedProvisioners:     &matchedProvisioners,
		TemplateVersionPresetID: presetID,
		HasExternalAgent:        hasExternalAgent,
	}, nil
}

func convertWorkspaceResource(resource database.WorkspaceResource, agents []codersdk.WorkspaceAgent, metadata []database.WorkspaceResourceMetadatum) codersdk.WorkspaceResource {
	var convertedMetadata []codersdk.WorkspaceResourceMetadata
	for _, field := range metadata {
		convertedMetadata = append(convertedMetadata, codersdk.WorkspaceResourceMetadata{
			Key:       field.Key,
			Value:     field.Value.String,
			Sensitive: field.Sensitive,
		})
	}

	return codersdk.WorkspaceResource{
		ID:         resource.ID,
		CreatedAt:  resource.CreatedAt,
		JobID:      resource.JobID,
		Transition: codersdk.WorkspaceTransition(resource.Transition),
		Type:       resource.Type,
		Name:       resource.Name,
		Hide:       resource.Hide,
		Icon:       resource.Icon,
		Agents:     agents,
		Metadata:   convertedMetadata,
		DailyCost:  resource.DailyCost,
	}
}

func (api *API) buildTimings(ctx context.Context, build database.WorkspaceBuild) (codersdk.WorkspaceBuildTimings, error) {
	provisionerTimings, err := api.Database.GetProvisionerJobTimingsByJobID(ctx, build.JobID)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching provisioner job timings: %w", err)
	}

	//nolint:gocritic // Already checked if the build can be fetched.
	agentScriptTimings, err := api.Database.GetWorkspaceAgentScriptTimingsByBuildID(dbauthz.AsSystemRestricted(ctx), build.ID)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching workspace agent script timings: %w", err)
	}

	resources, err := api.Database.GetWorkspaceResourcesByJobID(ctx, build.JobID)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching workspace resources: %w", err)
	}
	resourceIDs := make([]uuid.UUID, 0, len(resources))
	for _, resource := range resources {
		resourceIDs = append(resourceIDs, resource.ID)
	}
	//nolint:gocritic // Already checked if the build can be fetched.
	agents, err := api.Database.GetWorkspaceAgentsByResourceIDs(dbauthz.AsSystemRestricted(ctx), resourceIDs)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching workspace agents: %w", err)
	}

	res := codersdk.WorkspaceBuildTimings{
		ProvisionerTimings:     make([]codersdk.ProvisionerTiming, 0, len(provisionerTimings)),
		AgentScriptTimings:     make([]codersdk.AgentScriptTiming, 0, len(agentScriptTimings)),
		AgentConnectionTimings: make([]codersdk.AgentConnectionTiming, 0, len(agents)),
	}

	for _, t := range provisionerTimings {
		// Ref: #15432: agent script timings must not have a zero start or end time.
		if t.StartedAt.IsZero() || t.EndedAt.IsZero() {
			api.Logger.Debug(ctx, "ignoring provisioner timing with zero start or end time",
				slog.F("workspace_id", build.WorkspaceID),
				slog.F("workspace_build_id", build.ID),
				slog.F("provisioner_job_id", t.JobID),
			)
			continue
		}

		res.ProvisionerTimings = append(res.ProvisionerTimings, codersdk.ProvisionerTiming{
			JobID:     t.JobID,
			Stage:     codersdk.TimingStage(t.Stage),
			Source:    t.Source,
			Action:    t.Action,
			Resource:  t.Resource,
			StartedAt: t.StartedAt,
			EndedAt:   t.EndedAt,
		})
	}
	for _, t := range agentScriptTimings {
		// Ref: #15432: agent script timings must not have a zero start or end time.
		if t.StartedAt.IsZero() || t.EndedAt.IsZero() {
			api.Logger.Debug(ctx, "ignoring agent script timing with zero start or end time",
				slog.F("workspace_id", build.WorkspaceID),
				slog.F("workspace_agent_id", t.WorkspaceAgentID),
				slog.F("workspace_build_id", build.ID),
				slog.F("workspace_agent_script_id", t.ScriptID),
			)
			continue
		}

		res.AgentScriptTimings = append(res.AgentScriptTimings, codersdk.AgentScriptTiming{
			StartedAt:          t.StartedAt,
			EndedAt:            t.EndedAt,
			ExitCode:           t.ExitCode,
			Stage:              codersdk.TimingStage(t.Stage),
			Status:             codersdk.WorkspaceAgentScriptStatus(t.Status),
			DisplayName:        t.DisplayName,
			WorkspaceAgentID:   t.WorkspaceAgentID.String(),
			WorkspaceAgentName: t.WorkspaceAgentName,
		})
	}
	for _, agent := range agents {
		if agent.FirstConnectedAt.Time.IsZero() {
			api.Logger.Debug(ctx, "ignoring agent connection timing with zero first connected time",
				slog.F("workspace_id", build.WorkspaceID),
				slog.F("workspace_agent_id", agent.ID),
				slog.F("workspace_build_id", build.ID),
			)
			continue
		}
		res.AgentConnectionTimings = append(res.AgentConnectionTimings, codersdk.AgentConnectionTiming{
			WorkspaceAgentID:   agent.ID.String(),
			WorkspaceAgentName: agent.Name,
			StartedAt:          agent.CreatedAt,
			Stage:              codersdk.TimingStageConnect,
			EndedAt:            agent.FirstConnectedAt.Time,
		})
	}

	return res, nil
}