Ctrl K
ComunidadGuías…

coderd/workspaces.go

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

Archivo original ↗Preguntar a KiBot
package coderd

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"slices"
	"strconv"
	"strings"
	"time"
	"unicode"

	"github.com/dustin/go-humanize"
	"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/agent/proto"
	"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/prebuilds"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/rbac/acl"
	"github.com/coder/coder/v2/coderd/rbac/policy"
	"github.com/coder/coder/v2/coderd/schedule"
	"github.com/coder/coder/v2/coderd/schedule/cron"
	"github.com/coder/coder/v2/coderd/searchquery"
	"github.com/coder/coder/v2/coderd/telemetry"
	"github.com/coder/coder/v2/coderd/util/ptr"
	"github.com/coder/coder/v2/coderd/util/slice"
	"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/agentsdk"
	"github.com/coder/coder/v2/codersdk/wsjson"
	"github.com/coder/coder/v2/codersdk/wsrelated"
	"github.com/coder/websocket"
)

var (
	ttlMinimum = time.Minute
	ttlMaximum = 30 * 24 * time.Hour

	errTTLMin              = xerrors.New("time until shutdown must be at least one minute")
	errTTLMax              = xerrors.New("time until shutdown must be less than 30 days")
	errDeadlineTooSoon     = xerrors.New("new deadline must be at least 30 minutes in the future")
	errDeadlineBeforeStart = xerrors.New("new deadline must be before workspace start time")
)

// @Summary Get workspace metadata by ID
// @ID get-workspace-metadata-by-id
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param include_deleted query bool false "Return data instead of HTTP 404 if the workspace is deleted"
// @Param include_related query string false "Comma-separated list of related data to include (e.g. `template,latest_build.resources.agents.*`). Omit to include everything."
// @Success 200 {object} codersdk.Workspace
// @Router /api/v2/workspaces/{workspace} [get]
func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspace := httpmw.WorkspaceParam(r)
	apiKey := httpmw.APIKey(r)

	var (
		deletedStr  = r.URL.Query().Get("include_deleted")
		showDeleted = false
	)
	if deletedStr != "" {
		var err error
		showDeleted, err = strconv.ParseBool(deletedStr)
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: fmt.Sprintf("Invalid boolean value %q for \"include_deleted\" query param.", deletedStr),
				Validations: []codersdk.ValidationError{
					{Field: "deleted", Detail: "Must be a valid boolean"},
				},
			})
			return
		}
	}
	if workspace.Deleted && !showDeleted {
		httpapi.Write(ctx, rw, http.StatusGone, codersdk.Response{
			Message: fmt.Sprintf("Workspace %q was deleted, you can view this workspace by specifying '?include_deleted=true' and trying again.", workspace.ID.String()),
		})
		return
	}

	// Omitting include_related is backward compatible: everything is returned.
	// When present, only the requested related data is loaded.
	related := wsrelated.All()
	if r.URL.Query().Has("include_related") {
		var err error
		related, err = wsrelated.Parse(r.URL.Query().Get("include_related"))
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Invalid include_related query param.",
				Detail:  err.Error(),
			})
			return
		}
	}

	data, err := api.singleWorkspaceData(ctx, workspace, related)
	if err != nil {
		// The template was requested but the actor cannot read it.
		if errors.Is(err, errWorkspaceTemplateUnauthorized) {
			httpapi.Forbidden(rw)
			return
		}
		// The build was requested but does not exist.
		if errors.Is(err, errWorkspaceBuildNotFound) {
			httpapi.ResourceNotFound(rw)
			return
		}
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace resources.",
			Detail:  err.Error(),
		})
		return
	}

	appStatus := codersdk.WorkspaceAppStatus{}
	if len(data.appStatuses) > 0 {
		appStatus = data.appStatuses[0]
	}

	// Related data may be omitted, in which case these carry zero values.
	var build codersdk.WorkspaceBuild
	if len(data.builds) > 0 {
		build = data.builds[0]
	}
	var template database.Template
	if len(data.templates) > 0 {
		template = data.templates[0]
	}

	w, err := convertWorkspace(
		ctx,
		api.Logger,
		apiKey.UserID,
		workspace,
		build,
		template,
		api.AllowWorkspaceRenames,
		appStatus,
	)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace.",
			Detail:  err.Error(),
		})
		return
	}
	httpapi.Write(ctx, rw, http.StatusOK, w)
}

// workspaces returns all workspaces a user can read.
// Optional filters with query params
//
// @Summary List workspaces
// @ID list-workspaces
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items)."
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.WorkspacesResponse
// @Router /api/v2/workspaces [get]
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)

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

	queryStr := r.URL.Query().Get("q")
	filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout, apiKey.UserID)
	if len(errs) > 0 {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid workspace search query.",
			Validations: errs,
		})
		return
	}

	if filter.OwnerUsername == "me" {
		filter.OwnerID = apiKey.UserID
		filter.OwnerUsername = ""
	}

	// To show the requester's favorite workspaces first, we pass their userID and compare it to
	// the workspace owner_id when ordering the rows.
	filter.RequesterID = apiKey.UserID

	// We need the technical row to present the correct count on every page.
	filter.WithSummary = true

	// GetWorkspaces authorizes the query itself, so we don't prepare a SQL
	// filter here.
	workspaceRows, err := api.Database.GetWorkspaces(ctx, filter)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspaces.",
			Detail:  err.Error(),
		})
		return
	}

	if len(workspaceRows) == 0 {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspaces.",
			Detail:  "Workspace summary row is missing.",
		})
		return
	}
	if len(workspaceRows) == 1 {
		httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
			Workspaces: []codersdk.Workspace{},
			Count:      int(workspaceRows[0].Count),
		})
		return
	}
	// Skip technical summary row
	workspaceRows = workspaceRows[:len(workspaceRows)-1]

	if len(workspaceRows) == 0 {
		httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
			Workspaces: []codersdk.Workspace{},
			Count:      0,
		})
		return
	}

	workspaces, err := database.ConvertWorkspaceRows(workspaceRows)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace rows.",
			Detail:  err.Error(),
		})
		return
	}

	data, err := api.workspaceData(ctx, workspaces, wsrelated.All())
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace resources.",
			Detail:  err.Error(),
		})
		return
	}

	wss, err := convertWorkspaces(
		ctx,
		api.Logger,
		apiKey.UserID,
		workspaces,
		data,
	)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspaces.",
			Detail:  err.Error(),
		})
		return
	}

	if len(filter.IncludeAgentMetadata) > 0 {
		attachAgentMetadata(ctx, api.Logger, wss, workspaceRows)
	}

	httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
		Workspaces: wss,
		Count:      int(workspaceRows[0].Count),
	})
}

// @Summary Get workspace metadata by user and workspace name
// @ID get-workspace-metadata-by-user-and-workspace-name
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param user path string true "User ID, name, or me"
// @Param workspacename path string true "Workspace name"
// @Param include_deleted query bool false "Return data instead of HTTP 404 if the workspace is deleted"
// @Param include_related query string false "Comma-separated list of related data to include (e.g. `template,latest_build.resources.agents.*`). Omit to include everything."
// @Success 200 {object} codersdk.Workspace
// @Router /api/v2/users/{user}/workspace/{workspacename} [get]
func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	mems := httpmw.OrganizationMembersParam(r)
	workspaceName := chi.URLParam(r, "workspacename")
	apiKey := httpmw.APIKey(r)

	includeDeleted := false
	if s := r.URL.Query().Get("include_deleted"); s != "" {
		var err error
		includeDeleted, err = strconv.ParseBool(s)
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: fmt.Sprintf("Invalid boolean value %q for \"include_deleted\" query param.", s),
				Validations: []codersdk.ValidationError{
					{Field: "include_deleted", Detail: "Must be a valid boolean"},
				},
			})
			return
		}
	}

	workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
		OwnerID: mems.UserID(),
		Name:    workspaceName,
	})
	if includeDeleted && errors.Is(err, sql.ErrNoRows) {
		workspace, err = api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
			OwnerID: mems.UserID(),
			Name:    workspaceName,
			Deleted: includeDeleted,
		})
	}
	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
	}

	// Omitting include_related is backward compatible: everything is returned.
	// When present, only the requested related data is loaded.
	related := wsrelated.All()
	if r.URL.Query().Has("include_related") {
		related, err = wsrelated.Parse(r.URL.Query().Get("include_related"))
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Invalid include_related query param.",
				Detail:  err.Error(),
			})
			return
		}
	}

	data, err := api.singleWorkspaceData(ctx, workspace, related)
	if err != nil {
		// Preserve concealment: a template the actor cannot read, or a build
		// that does not exist, is reported as not found rather than forbidden.
		if errors.Is(err, errWorkspaceTemplateUnauthorized) || errors.Is(err, errWorkspaceBuildNotFound) {
			httpapi.ResourceNotFound(rw)
			return
		}
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace resources.",
			Detail:  err.Error(),
		})
		return
	}

	appStatus := codersdk.WorkspaceAppStatus{}
	if len(data.appStatuses) > 0 {
		appStatus = data.appStatuses[0]
	}

	// Related data may be omitted, in which case these carry zero values.
	var build codersdk.WorkspaceBuild
	if len(data.builds) > 0 {
		build = data.builds[0]
	}
	var template database.Template
	if len(data.templates) > 0 {
		template = data.templates[0]
	}

	w, err := convertWorkspace(
		ctx,
		api.Logger,
		apiKey.UserID,
		workspace,
		build,
		template,
		api.AllowWorkspaceRenames,
		appStatus,
	)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace.",
			Detail:  err.Error(),
		})
		return
	}
	httpapi.Write(ctx, rw, http.StatusOK, w)
}

// Create a new workspace for the currently authenticated user.
//
// @Summary Create user workspace by organization
// @Description Create a new workspace using a template. The request must
// @Description specify either the Template ID or the Template Version ID,
// @Description not both. If the Template ID is specified, the active version
// @Description of the template will be used.
// @Deprecated Use /users/{user}/workspaces instead.
// @ID create-user-workspace-by-organization
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Workspaces
// @Param organization path string true "Organization ID" format(uuid)
// @Param user path string true "Username, UUID, or me"
// @Param request body codersdk.CreateWorkspaceRequest true "Create workspace request"
// @Success 201 {object} codersdk.Workspace
// @Router /api/v2/organizations/{organization}/members/{user}/workspaces [post]
func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx                   = r.Context()
		apiKey                = httpmw.APIKey(r)
		auditor               = api.Auditor.Load()
		organization          = httpmw.OrganizationParam(r)
		member                = httpmw.OrganizationMemberParam(r)
		workspaceResourceInfo = audit.AdditionalFields{
			WorkspaceOwner: member.Username,
		}
	)

	aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
		Audit:            *auditor,
		Log:              api.Logger,
		Request:          r,
		Action:           database.AuditActionCreate,
		AdditionalFields: workspaceResourceInfo,
		OrganizationID:   organization.ID,
	})

	defer commitAudit()

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

	owner := workspaceOwner{
		ID:        member.UserID,
		Username:  member.Username,
		AvatarURL: member.AvatarURL,
	}

	w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{
		remoteAddr: r.RemoteAddr,
	})
	if err != nil {
		httperror.WriteResponseError(ctx, rw, err)
		return
	}

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

// Create a new workspace for the currently authenticated user.
//
// @Summary Create user workspace
// @Description Create a new workspace using a template. The request must
// @Description specify either the Template ID or the Template Version ID,
// @Description not both. If the Template ID is specified, the active version
// @Description of the template will be used.
// @ID create-user-workspace
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Workspaces
// @Param user path string true "Username, UUID, or me"
// @Param request body codersdk.CreateWorkspaceRequest true "Create workspace request"
// @Success 201 {object} codersdk.Workspace
// @Router /api/v2/users/{user}/workspaces [post]
func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx     = r.Context()
		apiKey  = httpmw.APIKey(r)
		auditor = api.Auditor.Load()
		mems    = httpmw.OrganizationMembersParam(r)
	)

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

	var owner workspaceOwner
	if mems.User != nil {
		// This user fetch is an optimization path for the most common case of creating a
		// workspace 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 workspace can still be created if the caller can read the organization
		// member. The organization is required, which can be sourced from the
		// template.
		//
		// TODO: This code gets called twice for each workspace build request.
		//   This is inefficient and costs at most 2 extra RTTs to the DB.
		//   This can be optimized. It exists as it is now for code simplicity.
		//   The most common case is to create a workspace for 'Me'. Which does
		//   not enter this code branch.
		template, err := requestTemplate(ctx, req, api.Database)
		if err != nil {
			httperror.WriteResponseError(ctx, rw, err)
			return
		}

		// 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 == template.OrganizationID
		})
		if orgIndex == -1 {
			httpapi.ResourceNotFound(rw)
			return
		}

		member := mems.Memberships[orgIndex]
		owner = workspaceOwner{
			ID:        member.UserID,
			Username:  member.Username,
			AvatarURL: member.AvatarURL,
		}
	}

	aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
		Audit:   *auditor,
		Log:     api.Logger,
		Request: r,
		Action:  database.AuditActionCreate,
		AdditionalFields: audit.AdditionalFields{
			WorkspaceOwner: owner.Username,
		},
	})

	defer commitAudit()

	w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{
		remoteAddr: r.RemoteAddr,
	})
	if err != nil {
		httperror.WriteResponseError(ctx, rw, err)
		return
	}

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

type workspaceOwner struct {
	ID        uuid.UUID
	Username  string
	AvatarURL string
}

type createWorkspaceOptions struct {
	// preCreateInTX is a function that is called within the transaction, before
	// the workspace is created.
	preCreateInTX func(ctx context.Context, tx database.Store) error
	// postBuildInTX is a function that is called within the transaction, after
	// the workspace and its first build are created.
	postBuildInTX func(ctx context.Context, tx database.Store, workspace database.Workspace, build database.WorkspaceBuild) error
	// remoteAddr is the IP address of the request initiator, used for
	// audit logging. HTTP handlers should pass r.RemoteAddr;
	// programmatic callers may leave it empty.
	remoteAddr string
}

func createWorkspace(
	ctx context.Context,
	auditReq *audit.Request[database.WorkspaceTable],
	initiatorID uuid.UUID,
	api *API,
	owner workspaceOwner,
	req codersdk.CreateWorkspaceRequest,
	opts *createWorkspaceOptions,
) (codersdk.Workspace, error) {
	if opts == nil {
		opts = &createWorkspaceOptions{}
	}

	template, err := api.preflightWorkspaceCreate(ctx, initiatorID, owner, req)
	if err != nil {
		return codersdk.Workspace{}, err
	}

	// Update audit log's organization
	auditReq.UpdateOrganizationID(template.OrganizationID)

	// Required external auth is otherwise only enforced by client-side preflight
	// checks in the CLI and UI, so API-created workspaces must be validated here
	// before any workspace row is inserted or prebuilt workspace is claimed.
	templateVersionID := req.TemplateVersionID
	if templateVersionID == uuid.Nil {
		templateVersionID = template.ActiveVersionID
	}
	templateVersion, err := api.Database.GetTemplateVersionByID(ctx, templateVersionID)
	if err != nil {
		if httpapi.Is404Error(err) {
			return codersdk.Workspace{}, httperror.ErrResourceNotFound
		}
		return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching template version.",
			Detail:  err.Error(),
		})
	}
	if err := api.requireWorkspaceOwnerExternalAuth(ctx, templateVersion, owner.ID); err != nil {
		return codersdk.Workspace{}, err
	}
	if err := api.validateWorkspaceSecretInputs(req.Secrets); err != nil {
		return codersdk.Workspace{}, err
	}

	dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule)
	if err != nil {
		return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid Autostart Schedule.",
			Validations: []codersdk.ValidationError{{Field: "schedule", Detail: err.Error()}},
		})
	}

	templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID)
	if err != nil {
		return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching template schedule.",
			Detail:  err.Error(),
		})
	}

	nextStartAt := sql.NullTime{}
	if dbAutostartSchedule.Valid {
		next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbAutostartSchedule.String, templateSchedule)
		if err == nil {
			nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
		}
	}

	dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, templateSchedule.DefaultTTL)
	if err != nil {
		return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid Workspace Time to Shutdown.",
			Validations: []codersdk.ValidationError{{Field: "ttl_ms", Detail: err.Error()}},
		})
	}

	// back-compatibility: default to "never" if not included.
	dbAU := database.AutomaticUpdatesNever
	if req.AutomaticUpdates != "" {
		dbAU, err = validWorkspaceAutomaticUpdates(req.AutomaticUpdates)
		if err != nil {
			return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
				Message:     "Invalid Workspace Automatic Updates setting.",
				Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: err.Error()}},
			})
		}
	}

	// TODO: This should be a system call as the actor might not be able to
	// read other workspaces. Ideally we check the error on create and look for
	// a postgres conflict error.
	workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
		OwnerID: owner.ID,
		Name:    req.Name,
	})
	if err == nil {
		// If the workspace already exists, don't allow creation.
		return codersdk.Workspace{}, httperror.NewResponseError(http.StatusConflict, codersdk.Response{
			Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
			Validations: []codersdk.ValidationError{{
				Field:  "name",
				Detail: "This value is already in use and should be unique.",
			}},
		})
	} else if !errors.Is(err, sql.ErrNoRows) {
		return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
			Message: fmt.Sprintf("Internal error fetching workspace by name %q.", req.Name),
			Detail:  err.Error(),
		})
	}

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

	err = api.Database.InTx(func(db database.Store) error {
		var (
			prebuildsClaimer = *api.PrebuildsClaimer.Load()
			workspaceID      uuid.UUID
			claimedWorkspace *database.Workspace
		)

		// If a preCreate hook is provided, execute it before creating or
		// claiming the workspace. This can be used to perform additional
		// setup or validation before the workspace is created (e.g. task
		// creation).
		if opts.preCreateInTX != nil {
			if err := opts.preCreateInTX(ctx, db); err != nil {
				return xerrors.Errorf("workspace preCreate failed: %w", err)
			}
		}

		// Use injected Clock to allow time mocking in tests
		now := dbtime.Time(api.Clock.Now())

		templateVersionPresetID := req.TemplateVersionPresetID

		// If no preset was chosen, look for a matching preset by parameter values.
		if templateVersionPresetID == uuid.Nil {
			parameterNames := make([]string, len(req.RichParameterValues))
			parameterValues := make([]string, len(req.RichParameterValues))
			for i, parameter := range req.RichParameterValues {
				parameterNames[i] = parameter.Name
				parameterValues[i] = parameter.Value
			}
			var err error
			templateVersionID := req.TemplateVersionID
			if templateVersionID == uuid.Nil {
				templateVersionID = template.ActiveVersionID
			}
			templateVersionPresetID, err = prebuilds.FindMatchingPresetID(ctx, db, templateVersionID, parameterNames, parameterValues)
			if err != nil {
				return xerrors.Errorf("find matching preset: %w", err)
			}
		}

		// Try to claim a prebuilt workspace.
		if templateVersionPresetID != uuid.Nil {
			// Try and claim an eligible prebuild, if available.
			// On successful claim, initialize all lifecycle fields from template and workspace-level config
			// so the newly claimed workspace is properly managed by the lifecycle executor.
			claimedWorkspace, err = claimPrebuild(
				ctx, prebuildsClaimer, db, api.Logger, now, req.Name, owner,
				templateVersionPresetID, dbAutostartSchedule, nextStartAt, dbTTL)
			// If claiming fails with an expected error (no claimable prebuilds or AGPL does not support prebuilds),
			// we fall back to creating a new workspace. Otherwise, propagate the unexpected error.
			if err != nil {
				isExpectedError := errors.Is(err, prebuilds.ErrNoClaimablePrebuiltWorkspaces) ||
					errors.Is(err, prebuilds.ErrAGPLDoesNotSupportPrebuiltWorkspaces)
				fields := []slog.Field{
					slog.Error(err),
					slog.F("workspace_name", req.Name),
					slog.F("template_version_preset_id", templateVersionPresetID),
				}

				if !isExpectedError {
					// if it's an unexpected error - use error log level
					api.Logger.Error(ctx, "failed to claim prebuilt workspace", fields...)

					return xerrors.Errorf("failed to claim prebuilt workspace: %w", err)
				}

				// if it's an expected error - use warn log level
				api.Logger.Warn(ctx, "failed to claim prebuilt workspace", fields...)

				// fall back to creating a new workspace
			}
		}

		// No prebuild found; regular flow.
		if claimedWorkspace == nil {
			// Workspaces are created without any versions.
			minimumWorkspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{
				ID:                uuid.New(),
				CreatedAt:         now,
				UpdatedAt:         now,
				OwnerID:           owner.ID,
				OrganizationID:    template.OrganizationID,
				TemplateID:        template.ID,
				Name:              req.Name,
				AutostartSchedule: dbAutostartSchedule,
				NextStartAt:       nextStartAt,
				Ttl:               dbTTL,
				// The workspaces page will sort by last used at, and it's useful to
				// have the newly created workspace at the top of the list!
				LastUsedAt:       now,
				AutomaticUpdates: dbAU,
			})
			if err != nil {
				return xerrors.Errorf("insert workspace: %w", err)
			}
			workspaceID = minimumWorkspace.ID
		} else {
			// Prebuild found!
			workspaceID = claimedWorkspace.ID
		}

		// We have to refetch the workspace for the joined in fields.
		// TODO: We can use WorkspaceTable for the builder to not require
		// this extra fetch.
		workspace, err = db.GetWorkspaceByID(ctx, workspaceID)
		if err != nil {
			return xerrors.Errorf("get workspace by ID: %w", err)
		}

		builder := wsbuilder.New(workspace, database.WorkspaceTransitionStart, *api.BuildUsageChecker.Load()).
			Reason(database.BuildReasonInitiator).
			Initiator(initiatorID).
			ActiveVersion().
			Experiments(api.Experiments).
			DeploymentValues(api.DeploymentValues).
			RichParameterValues(req.RichParameterValues).
			Secrets(req.Secrets).
			BuildMetrics(api.WorkspaceBuilderMetrics)
		if req.TemplateVersionID != uuid.Nil {
			builder = builder.VersionID(req.TemplateVersionID)
		}
		if templateVersionPresetID != uuid.Nil {
			builder = builder.TemplateVersionPresetID(templateVersionPresetID)
		}
		if claimedWorkspace != nil {
			builder = builder.MarkPrebuiltWorkspaceClaim()
		}

		workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
			ctx,
			db,
			api.FileCache,
			func(action policy.Action, object rbac.Objecter) bool {
				return api.HTTPAuth.AuthorizeContext(ctx, action, object)
			},
			audit.WorkspaceBuildBaggage{IP: opts.remoteAddr},
		)
		if err != nil {
			return err
		}

		if opts.postBuildInTX != nil {
			if err := opts.postBuildInTX(ctx, db, workspace, *workspaceBuild); err != nil {
				return xerrors.Errorf("workspace postBuild failed: %w", err)
			}
		}
		return nil
	}, nil)
	if err != nil {
		return codersdk.Workspace{}, err
	}

	err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob)
	if 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))
	}

	// 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 == initiatorID {
				continue
			}

			api.notifyWorkspaceCreated(ctx, admin.ID, workspace, req.RichParameterValues)
		}
	}

	auditReq.New = workspace.WorkspaceTable()

	// Report the stored parameters, not the request: coderd drops values for
	// parameters the template does not declare and fills in defaults and
	// presets, so this matches what the periodic snapshot reports.
	var storedParameters map[string]string
	if api.Telemetry.Enabled() {
		buildParameters, err := api.Database.GetWorkspaceBuildParameters(ctx, workspaceBuild.ID)
		if err != nil {
			api.Logger.Warn(ctx, "get workspace build parameters for telemetry", slog.Error(err))
		}
		storedParameters = make(map[string]string, len(buildParameters))
		for _, param := range buildParameters {
			storedParameters[param.Name] = param.Value
		}
	}
	api.Telemetry.Report(&telemetry.Snapshot{
		Workspaces:      []telemetry.Workspace{telemetry.ConvertWorkspace(workspace)},
		WorkspaceBuilds: []telemetry.WorkspaceBuild{telemetry.ConvertWorkspaceBuild(*workspaceBuild, storedParameters)},
	})

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

	w, err := convertWorkspace(
		ctx,
		api.Logger,
		initiatorID,
		workspace,
		apiBuild,
		template,
		api.AllowWorkspaceRenames,
		codersdk.WorkspaceAppStatus{},
	)
	if err != nil {
		return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace.",
			Detail:  err.Error(),
		})
	}

	return w, nil
}

// requireWorkspaceOwnerExternalAuth returns a 403 response error when the
// workspace owner has not authenticated with every required (non-optional)
// external auth provider referenced by the template version. Token injection
// at build time uses the owner's external auth links, so the owner is the
// subject of the check even when another user initiates the build.
func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error {
	//nolint:gocritic // Reads/refreshes the external auth links. Necessary when admins create workspaces for other users.
	providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsExternalAuthCoordinator(ctx), templateVersion, ownerID)
	if err != nil {
		return err
	}

	var (
		missingNames []string
		validations  []codersdk.ValidationError
	)
	for _, provider := range providers {
		if provider.Optional || provider.Authenticated {
			continue
		}
		name := provider.DisplayName
		if name == "" {
			name = provider.ID
		}
		// The authenticate URL is included so clients without a
		// preflight external auth check (such as the chat tools) can
		// surface a usable login link to the user.
		if provider.AuthenticateURL != "" {
			name = fmt.Sprintf("%s (%s)", name, provider.AuthenticateURL)
		}
		missingNames = append(missingNames, name)
		validations = append(validations, codersdk.ValidationError{
			Field:  "external_auth",
			Detail: provider.ID,
		})
	}
	if len(missingNames) == 0 {
		return nil
	}

	return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
		Message: "External authentication is required to create a workspace with this template.",
		Detail: fmt.Sprintf(
			"The workspace owner must authenticate with the following external auth providers: %s.",
			strings.Join(missingNames, ", "),
		),
		Validations: validations,
	})
}

// preflightWorkspaceCreate resolves the template targeted by req and verifies
// that the caller may create a workspace owned by ownerID using it. It performs
// only side-effect-free authorization, so it is safe to call as an early gate:
//
//   - resolve the template (requestTemplate)
//   - ActionCreate on a workspace in the template's organization for the owner
//   - ActionUse on the template
//   - reject deprecated templates
//
// It deliberately does not validate required external auth (that mutates the
// owner's external auth links and is enforced separately) and does not touch
// the audit request, so callers retain control over audit-organization
// assignment and external-auth ordering. Both createWorkspace and tasksCreate
// call it so these authorization gates cannot diverge.
func (api *API) preflightWorkspaceCreate(ctx context.Context, initiatorID uuid.UUID, owner workspaceOwner, req codersdk.CreateWorkspaceRequest) (database.Template, error) {
	template, err := requestTemplate(ctx, req, api.Database)
	if err != nil {
		return database.Template{}, err
	}

	// This is a premature auth check to avoid doing unnecessary work if the
	// user doesn't have permission to create a workspace.
	if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate,
		rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(owner.ID.String())) {
		// If this check fails, return a proper unauthorized error to the user
		// to indicate what is going on.
		if initiatorID != owner.ID {
			return database.Template{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
				Message: "You can't create workspaces for other users in this organization.",
				Detail: fmt.Sprintf("Creating a workspace for %q needs permission to create workspaces owned by other users in organization %q, "+
					"which your account doesn't have. Use an account with that permission (for example an organization admin), "+
					"or create the workspace as %q.", owner.Username, template.OrganizationName, owner.Username),
			})
		}
		return database.Template{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
			Message: "Unauthorized to create workspace.",
			Detail: "You are unable to create a workspace in this organization. " +
				"It is possible to have access to the template, but not be able to create a workspace. " +
				"Please contact an administrator about your permissions if you feel this is an error.",
		})
	}

	// The user also needs permission to use the template. At this point they
	// have read perms, but not necessarily "use". This is also checked in
	// `db.InsertWorkspace`. Doing this up front can save some work below if the
	// user doesn't have permission.
	if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionUse, template) {
		if initiatorID != owner.ID {
			return database.Template{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
				Message: fmt.Sprintf("You can't use the template %q to create workspaces for other users.", template.Name),
				Detail: fmt.Sprintf("Creating a workspace for %q from this template needs \"use\" access to template %q, "+
					"which your account doesn't have. Ask a template admin to grant it, or use an account that has it.",
					owner.Username, template.Name),
			})
		}
		return database.Template{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
			Message: fmt.Sprintf("Unauthorized access to use the template %q.", template.Name),
			Detail: "Although you are able to view the template, you are unable to create a workspace using it. " +
				"Please contact an administrator about your permissions if you feel this is an error.",
		})
	}

	templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
	if templateAccessControl.IsDeprecated() {
		return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: fmt.Sprintf("Template %q has been deprecated, and cannot be used to create a new workspace.", template.Name),
			// Pass the deprecated message to the user.
			Detail: templateAccessControl.Deprecated,
		})
	}

	return template, nil
}

func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) {
	// If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it.
	templateID := req.TemplateID

	if templateID == uuid.Nil {
		templateVersion, err := db.GetTemplateVersionByID(ctx, req.TemplateVersionID)
		if httpapi.Is404Error(err) {
			return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
				Message: fmt.Sprintf("Template version %q doesn't exist.", req.TemplateVersionID),
				Validations: []codersdk.ValidationError{{
					Field:  "template_version_id",
					Detail: "template not found",
				}},
			})
		}
		if err != nil {
			return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
				Message: "Internal error fetching template version.",
				Detail:  err.Error(),
			})
		}
		if templateVersion.Archived {
			return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
				Message: "Archived template versions cannot be used to make a workspace.",
				Validations: []codersdk.ValidationError{
					{
						Field:  "template_version_id",
						Detail: "template version archived",
					},
				},
			})
		}

		templateID = templateVersion.TemplateID.UUID
	}

	template, err := db.GetTemplateByID(ctx, templateID)
	if httpapi.Is404Error(err) {
		return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
			Message: fmt.Sprintf("Template %q doesn't exist.", templateID),
			Validations: []codersdk.ValidationError{{
				Field:  "template_id",
				Detail: "template not found",
			}},
		})
	}
	if err != nil {
		return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching template.",
			Detail:  err.Error(),
		})
	}
	if template.Deleted {
		return database.Template{}, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{
			Message: fmt.Sprintf("Template %q has been deleted!", template.Name),
		})
	}
	return template, nil
}

func claimPrebuild(
	ctx context.Context,
	claimer prebuilds.Claimer,
	db database.Store,
	logger slog.Logger,
	now time.Time,
	name string,
	owner workspaceOwner,
	templateVersionPresetID uuid.UUID,
	autostartSchedule sql.NullString,
	nextStartAt sql.NullTime,
	ttl sql.NullInt64,
) (*database.Workspace, error) {
	claimedID, err := claimer.Claim(ctx, db, now, owner.ID, name, templateVersionPresetID, autostartSchedule, nextStartAt, ttl)
	if err != nil {
		// TODO: enhance this by clarifying whether this *specific* prebuild failed or whether there are none to claim.
		return nil, xerrors.Errorf("claim prebuild: %w", err)
	}

	lookup, err := db.GetWorkspaceByID(ctx, *claimedID)
	if err != nil {
		logger.Error(ctx, "unable to find claimed workspace by ID", slog.Error(err), slog.F("claimed_prebuild_id", claimedID.String()))
		return nil, xerrors.Errorf("find claimed workspace by ID %q: %w", claimedID.String(), err)
	}
	return &lookup, nil
}

func (api *API) notifyWorkspaceCreated(
	ctx context.Context,
	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
	}

	owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
	if err != nil {
		log.Warn(ctx, "failed to fetch user for workspace creation notification", slog.F("owner_id", workspace.OwnerID), 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_version_id", template.ActiveVersionID), 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.TemplateWorkspaceCreated,
		map[string]string{
			"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-create",
		// 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 creation", slog.Error(err))
	}
}

// @Summary Update workspace metadata by ID
// @ID update-workspace-metadata-by-id
// @Security CoderSessionToken
// @Accept json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceRequest true "Metadata update request"
// @Success 204
// @Router /api/v2/workspaces/{workspace} [patch]
func (api *API) patchWorkspace(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx               = r.Context()
		workspace         = httpmw.WorkspaceParam(r)
		auditor           = api.Auditor.Load()
		aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
			Audit:          *auditor,
			Log:            api.Logger,
			Request:        r,
			Action:         database.AuditActionWrite,
			OrganizationID: workspace.OrganizationID,
		})
	)
	defer commitAudit()
	aReq.Old = workspace.WorkspaceTable()

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

	if req.Name == "" || req.Name == workspace.Name {
		aReq.New = workspace.WorkspaceTable()
		// Nothing changed, optionally this could be an error.
		rw.WriteHeader(http.StatusNoContent)
		return
	}

	// The reason we double check here is in case more fields can be
	// patched in the future, it's enough if one changes.
	name := workspace.Name
	if req.Name != "" || req.Name != workspace.Name {
		template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Internal error fetching workspace template.",
				Detail:  err.Error(),
			})
			return
		}
		if !api.AllowWorkspaceRenames && !template.AllowWorkspaceRenames {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Workspace renames are not enabled for this template.",
			})
			return
		}
		name = req.Name
	}

	newWorkspace, err := api.Database.UpdateWorkspace(ctx, database.UpdateWorkspaceParams{
		ID:   workspace.ID,
		Name: name,
	})
	if err != nil {
		// The query protects against updating deleted workspaces and
		// the existence of the workspace is checked in the request,
		// if we get ErrNoRows it means the workspace was deleted.
		//
		// We could do this check earlier but we'd need to start a
		// transaction.
		if errors.Is(err, sql.ErrNoRows) {
			httpapi.Write(ctx, rw, http.StatusMethodNotAllowed, codersdk.Response{
				Message: fmt.Sprintf("Workspace %q is deleted and cannot be updated.", workspace.Name),
			})
			return
		}
		// Check if the name was already in use.
		if database.IsUniqueViolation(err) {
			httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
				Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
				Validations: []codersdk.ValidationError{{
					Field:  "name",
					Detail: "This value is already in use and should be unique.",
				}},
			})
			return
		}
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error updating workspace.",
			Detail:  err.Error(),
		})
		return
	}

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

	aReq.New = newWorkspace

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Update workspace autostart schedule by ID
// @ID update-workspace-autostart-schedule-by-id
// @Security CoderSessionToken
// @Accept json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceAutostartRequest true "Schedule update request"
// @Success 204
// @Router /api/v2/workspaces/{workspace}/autostart [put]
func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx               = r.Context()
		workspace         = httpmw.WorkspaceParam(r)
		auditor           = api.Auditor.Load()
		aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
			Audit:          *auditor,
			Log:            api.Logger,
			Request:        r,
			Action:         database.AuditActionWrite,
			OrganizationID: workspace.OrganizationID,
		})
	)
	defer commitAudit()
	aReq.Old = workspace.WorkspaceTable()

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

	// Autostart configuration is not supported for prebuilt workspaces.
	// Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior
	// defined per preset at the template level, not per workspace.
	if workspace.IsPrebuild() {
		httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
			Message: "Autostart is not supported for prebuilt workspaces",
			Detail:  "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.",
		})
		return
	}

	dbSched, err := validWorkspaceSchedule(req.Schedule)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid autostart schedule.",
			Validations: []codersdk.ValidationError{{Field: "schedule", Detail: err.Error()}},
		})
		return
	}

	// Check if the template allows users to configure autostart.
	templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, workspace.TemplateID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error getting template schedule options.",
			Detail:  err.Error(),
		})
		return
	}
	if !templateSchedule.UserAutostartEnabled {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Autostart is not allowed for workspaces using this template.",
			Validations: []codersdk.ValidationError{{Field: "schedule", Detail: "Autostart is not allowed for workspaces using this template."}},
		})
		return
	}

	// Use injected Clock to allow time mocking in tests
	now := api.Clock.Now()

	nextStartAt := sql.NullTime{}
	if dbSched.Valid {
		next, err := schedule.NextAllowedAutostart(now, dbSched.String, templateSchedule)
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Internal error calculating workspace autostart schedule.",
				Detail:  err.Error(),
			})
			return
		}
		nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
	}

	err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
		ID:                workspace.ID,
		AutostartSchedule: dbSched,
		NextStartAt:       nextStartAt,
	})
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error updating workspace autostart schedule.",
			Detail:  err.Error(),
		})
		return
	}

	newWorkspace := workspace
	newWorkspace.AutostartSchedule = dbSched
	aReq.New = newWorkspace.WorkspaceTable()

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Update workspace TTL by ID
// @ID update-workspace-ttl-by-id
// @Security CoderSessionToken
// @Accept json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceTTLRequest true "Workspace TTL update request"
// @Success 204
// @Router /api/v2/workspaces/{workspace}/ttl [put]
func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx               = r.Context()
		workspace         = httpmw.WorkspaceParam(r)
		auditor           = api.Auditor.Load()
		aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
			Audit:          *auditor,
			Log:            api.Logger,
			Request:        r,
			Action:         database.AuditActionWrite,
			OrganizationID: workspace.OrganizationID,
		})
	)
	defer commitAudit()
	aReq.Old = workspace.WorkspaceTable()

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

	// TTL updates are not supported for prebuilt workspaces.
	// Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior
	// defined per preset at the template level, not per workspace.
	if workspace.IsPrebuild() {
		httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
			Message: "TTL updates are not supported for prebuilt workspaces",
			Detail:  "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.",
		})
		return
	}

	var dbTTL sql.NullInt64

	err := api.Database.InTx(func(s database.Store) error {
		templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, s, workspace.TemplateID)
		if err != nil {
			return xerrors.Errorf("get template schedule: %w", err)
		}
		if !templateSchedule.UserAutostopEnabled {
			return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."}
		}

		// don't override 0 ttl with template default here because it indicates
		// disabled autostop
		var validityErr error
		dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0)
		if validityErr != nil {
			return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()}
		}
		if err := s.UpdateWorkspaceTTL(ctx, database.UpdateWorkspaceTTLParams{
			ID:  workspace.ID,
			Ttl: dbTTL,
		}); err != nil {
			return xerrors.Errorf("update workspace time until shutdown: %w", err)
		}

		// Use injected Clock to allow time mocking in tests
		now := api.Clock.Now()

		// If autostop has been disabled, we want to remove the deadline from the
		// existing workspace build (if there is one).
		if !dbTTL.Valid {
			build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
			if err != nil {
				return xerrors.Errorf("get latest workspace build: %w", err)
			}

			if build.Transition == database.WorkspaceTransitionStart {
				if err = s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
					ID: build.ID,
					// Use the max_deadline as the new build deadline. It will
					// either be zero (our target), or a non-zero value that we
					// need to abide by anyway due to template policy.
					//
					// Previously, we would always set the deadline to zero,
					// which was incorrect behavior. When max_deadline is
					// non-zero, deadline must be set to a non-zero value that
					// is less than max_deadline.
					//
					// Disabling TTL autostop (at a workspace or template level)
					// does not trump the template's autostop requirement.
					//
					// Refer to the comments on schedule.CalculateAutostop for
					// more information.
					Deadline:    build.MaxDeadline,
					MaxDeadline: build.MaxDeadline,
					UpdatedAt:   dbtime.Time(now),
				}); err != nil {
					return xerrors.Errorf("update workspace build deadline: %w", err)
				}
			}
		}

		return nil
	}, nil)
	if err != nil {
		resp := codersdk.Response{
			Message: "Error updating workspace time until shutdown.",
		}
		var validErr codersdk.ValidationError
		if errors.As(err, &validErr) {
			resp.Validations = []codersdk.ValidationError{validErr}
			httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
			return
		}

		resp.Detail = err.Error()
		httpapi.Write(ctx, rw, http.StatusInternalServerError, resp)
		return
	}

	newWorkspace := workspace
	newWorkspace.Ttl = dbTTL
	aReq.New = newWorkspace.WorkspaceTable()

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Update workspace dormancy status by id.
// @ID update-workspace-dormancy-status-by-id
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceDormancy true "Make a workspace dormant or active"
// @Success 200 {object} codersdk.Workspace
// @Router /api/v2/workspaces/{workspace}/dormant [put]
func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx               = r.Context()
		oldWorkspace      = httpmw.WorkspaceParam(r)
		apiKey            = httpmw.APIKey(r)
		auditor           = api.Auditor.Load()
		aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
			Audit:          *auditor,
			Log:            api.Logger,
			Request:        r,
			Action:         database.AuditActionWrite,
			OrganizationID: oldWorkspace.OrganizationID,
		})
	)
	aReq.Old = oldWorkspace.WorkspaceTable()
	defer commitAudit()

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

	// Dormancy configuration is not supported for prebuilt workspaces.
	// Prebuilds are managed by the reconciliation loop and are not subject to dormancy.
	if oldWorkspace.IsPrebuild() {
		httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
			Message: "Dormancy updates are not supported for prebuilt workspaces",
			Detail:  "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces",
		})
		return
	}

	// If the workspace is already in the desired state do nothing!
	if oldWorkspace.DormantAt.Valid == req.Dormant {
		rw.WriteHeader(http.StatusNotModified)
		return
	}

	// Use injected Clock to allow time mocking in tests
	now := api.Clock.Now()

	dormantAt := sql.NullTime{
		Valid: req.Dormant,
	}
	if req.Dormant {
		dormantAt.Time = dbtime.Time(now)
	}

	newWorkspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
		ID:        oldWorkspace.ID,
		DormantAt: dormantAt,
	})
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error updating workspace locked status.",
			Detail:  err.Error(),
		})
		return
	}

	// We don't need to notify the owner if they are the one making the request.
	if req.Dormant && apiKey.UserID != newWorkspace.OwnerID {
		initiator, initiatorErr := api.Database.GetUserByID(ctx, apiKey.UserID)
		if initiatorErr != nil {
			api.Logger.Warn(
				ctx,
				"failed to fetch the user that marked the workspace as dormant",
				slog.Error(err),
				slog.F("workspace_id", newWorkspace.ID),
				slog.F("user_id", apiKey.UserID),
			)
		}

		if initiatorErr == nil {
			labels := map[string]string{
				"name":   newWorkspace.Name,
				"reason": "a " + initiator.Username + " request",
			}
			// DeletingAt is set by the UPDATE only when the template's
			// time_til_dormant_autodelete is non-zero, so skip the label when
			// auto-delete is disabled so the body omits the deletion
			// timeline.
			if newWorkspace.DeletingAt.Valid {
				labels["timeTilDelete"] = humanize.Time(newWorkspace.DeletingAt.Time)
			}
			_, err = api.NotificationsEnqueuer.Enqueue(
				// nolint:gocritic // Need notifier actor to enqueue notifications
				dbauthz.AsNotifier(ctx),
				newWorkspace.OwnerID,
				notifications.TemplateWorkspaceDormant,
				labels,
				"api",
				newWorkspace.ID,
				newWorkspace.OwnerID,
				newWorkspace.TemplateID,
				newWorkspace.OrganizationID,
			)
			if err != nil {
				api.Logger.Warn(ctx, "failed to notify of workspace marked as dormant", slog.Error(err))
			}
		}
	}

	// We have to refetch the workspace to get the joined in fields.
	workspace, err := api.Database.GetWorkspaceByID(ctx, newWorkspace.ID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace.",
			Detail:  err.Error(),
		})
		return
	}

	data, err := api.singleWorkspaceData(ctx, workspace, wsrelated.All())
	if err != nil {
		// TODO: This is a strange error since it occurs after the mutation.
		// An example of why we should join in fields to prevent this forbidden
		// error from being sent, when the action did succeed.
		if errors.Is(err, errWorkspaceTemplateUnauthorized) {
			httpapi.Forbidden(rw)
			return
		}
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching workspace resources.",
			Detail:  err.Error(),
		})
		return
	}

	aReq.New = newWorkspace

	appStatus := codersdk.WorkspaceAppStatus{}
	if len(data.appStatuses) > 0 {
		appStatus = data.appStatuses[0]
	}

	w, err := convertWorkspace(
		ctx,
		api.Logger,
		apiKey.UserID,
		workspace,
		data.builds[0],
		data.templates[0],
		api.AllowWorkspaceRenames,
		appStatus,
	)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting workspace.",
			Detail:  err.Error(),
		})
		return
	}
	httpapi.Write(ctx, rw, http.StatusOK, w)
}

// @Summary Extend workspace deadline by ID
// @ID extend-workspace-deadline-by-id
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.PutExtendWorkspaceRequest true "Extend deadline update request"
// @Success 200 {object} codersdk.Response
// @Router /api/v2/workspaces/{workspace}/extend [put]
func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	workspace := httpmw.WorkspaceParam(r)

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

	// Deadline extensions are not supported for prebuilt workspaces.
	// Prebuilds are managed by the reconciliation loop and must always have
	// Deadline and MaxDeadline unset.
	if workspace.IsPrebuild() {
		httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
			Message: "Deadline extension is not supported for prebuilt workspaces",
			Detail:  "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces",
		})
		return
	}

	code := http.StatusOK
	resp := codersdk.Response{}

	err := api.Database.InTx(func(s database.Store) error {
		build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
		if err != nil {
			code = http.StatusInternalServerError
			resp.Message = "Error fetching workspace build."
			return xerrors.Errorf("get latest workspace build: %w", err)
		}

		job, err := s.GetProvisionerJobByID(ctx, build.JobID)
		if err != nil {
			code = http.StatusInternalServerError
			resp.Message = "Error fetching workspace provisioner job."
			return xerrors.Errorf("get provisioner job: %w", err)
		}

		if build.Transition != database.WorkspaceTransitionStart {
			code = http.StatusConflict
			resp.Message = "Workspace must be started, current status: " + string(build.Transition)
			return xerrors.Errorf("workspace must be started, current status: %s", build.Transition)
		}

		if !job.CompletedAt.Valid {
			code = http.StatusConflict
			resp.Message = "Workspace is still building!"
			return xerrors.Errorf("workspace is still building")
		}

		if build.Deadline.IsZero() {
			code = http.StatusConflict
			resp.Message = "Workspace shutdown is manual."
			return xerrors.Errorf("workspace shutdown is manual")
		}

		// Use injected Clock to allow time mocking in tests
		now := api.Clock.Now()

		newDeadline := req.Deadline.UTC()
		if err := validWorkspaceDeadline(now, job.CompletedAt.Time, newDeadline); err != nil {
			// NOTE(Cian): Putting the error in the Message field on request from the FE folks.
			// Normally, we would put the validation error in Validations, but this endpoint is
			// not tied to a form or specific named user input on the FE.
			code = http.StatusBadRequest
			resp.Message = "Cannot extend workspace: " + err.Error()
			return err
		}
		if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) {
			code = http.StatusBadRequest
			resp.Message = "Cannot extend workspace beyond max deadline."
			return xerrors.New("Cannot extend workspace: deadline is beyond max deadline imposed by template")
		}

		if err := s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
			ID:          build.ID,
			UpdatedAt:   dbtime.Time(now),
			Deadline:    newDeadline,
			MaxDeadline: build.MaxDeadline,
		}); err != nil {
			code = http.StatusInternalServerError
			resp.Message = "Failed to extend workspace deadline."
			return xerrors.Errorf("update workspace build: %w", err)
		}
		resp.Message = "Deadline updated to " + newDeadline.Format(time.RFC3339) + "."

		return nil
	}, nil)
	if err != nil {
		api.Logger.Info(ctx, "extending workspace", slog.Error(err))
	}

	api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
		Kind:        wspubsub.WorkspaceEventKindMetadataUpdate,
		WorkspaceID: workspace.ID,
	})
	httpapi.Write(ctx, rw, code, resp)
}

// @Summary Post Workspace Usage by ID
// @ID post-workspace-usage-by-id
// @Security CoderSessionToken
// @Tags Workspaces
// @Accept json
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.PostWorkspaceUsageRequest false "Post workspace usage request"
// @Success 204
// @Router /api/v2/workspaces/{workspace}/usage [post]
func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
	workspace := httpmw.WorkspaceParam(r)
	if !api.Authorize(r, policy.ActionUpdate, workspace) {
		httpapi.Forbidden(rw)
		return
	}

	api.statsReporter.TrackUsage(workspace.ID)

	if !api.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) {
		// Continue previous behavior if the experiment is not enabled.
		rw.WriteHeader(http.StatusNoContent)
		return
	}

	if r.Body == http.NoBody {
		// Continue previous behavior if no body is present.
		rw.WriteHeader(http.StatusNoContent)
		return
	}

	ctx := r.Context()
	var req codersdk.PostWorkspaceUsageRequest
	if !httpapi.Read(ctx, rw, r, &req) {
		return
	}

	// Normalize at the edge so storage and lookup agree on the key, and so a
	// name that carries no information reads the same as an absent one.
	appName := normalizeUsageAppName(req.AppName)

	if req.AgentID == uuid.Nil && appName == "" {
		// Continue previous behavior if body is empty.
		rw.WriteHeader(http.StatusNoContent)
		return
	}
	if req.AgentID == uuid.Nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Invalid request",
			Validations: []codersdk.ValidationError{{
				Field:  "agent_id",
				Detail: "must be set when app_name is set",
			}},
		})
		return
	}
	if appName == "" {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Invalid request",
			Validations: []codersdk.ValidationError{{
				Field:  "app_name",
				Detail: "must be set when agent_id is set",
			}},
		})
		return
	}

	stat := &proto.Stats{
		ConnectionCount: 1,
		SessionCounts:   map[string]int64{appName: 1},
	}

	agent, err := api.Database.GetWorkspaceAgentByID(ctx, req.AgentID)
	if err != nil {
		if httpapi.Is404Error(err) {
			httpapi.ResourceNotFound(rw)
			return
		}
		httpapi.InternalServerError(rw, err)
		return
	}

	err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), database.WorkspaceIdentityFromWorkspace(workspace), agent.ID, agent.Name, stat, true)
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return
	}

	rw.WriteHeader(http.StatusNoContent)
}

// normalizeUsageAppName prepares a client-supplied app name for storage. A
// name of only whitespace and control characters carries no app, so it
// returns the empty string and the caller rejects the request rather than
// counting a session under the unknown family.
func normalizeUsageAppName(appName string) string {
	named := strings.ContainsFunc(appName, func(r rune) bool {
		return !unicode.IsControl(r) && !unicode.IsSpace(r)
	})
	if !named {
		return ""
	}
	return codersdk.NormalizeAppName(appName)
}

// @Summary Favorite workspace by ID.
// @ID favorite-workspace-by-id
// @Security CoderSessionToken
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 204
// @Router /api/v2/workspaces/{workspace}/favorite [put]
func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx       = r.Context()
		workspace = httpmw.WorkspaceParam(r)
		apiKey    = httpmw.APIKey(r)
		auditor   = api.Auditor.Load()
	)

	if apiKey.UserID != workspace.OwnerID {
		httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
			Message: "You can only favorite workspaces that you own.",
		})
		return
	}

	aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
		Audit:          *auditor,
		Log:            api.Logger,
		Request:        r,
		Action:         database.AuditActionWrite,
		OrganizationID: workspace.OrganizationID,
	})
	defer commitAudit()
	aReq.Old = workspace.WorkspaceTable()

	err := api.Database.FavoriteWorkspace(ctx, workspace.ID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error setting workspace as favorite",
			Detail:  err.Error(),
		})
		return
	}

	aReq.New = workspace.WorkspaceTable()
	aReq.New.Favorite = true

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Unfavorite workspace by ID.
// @ID unfavorite-workspace-by-id
// @Security CoderSessionToken
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 204
// @Router /api/v2/workspaces/{workspace}/favorite [delete]
func (api *API) deleteFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx       = r.Context()
		workspace = httpmw.WorkspaceParam(r)
		apiKey    = httpmw.APIKey(r)
		auditor   = api.Auditor.Load()
	)

	if apiKey.UserID != workspace.OwnerID {
		httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
			Message: "You can only un-favorite workspaces that you own.",
		})
		return
	}

	aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
		Audit:          *auditor,
		Log:            api.Logger,
		Request:        r,
		Action:         database.AuditActionWrite,
		OrganizationID: workspace.OrganizationID,
	})

	defer commitAudit()
	aReq.Old = workspace.WorkspaceTable()

	err := api.Database.UnfavoriteWorkspace(ctx, workspace.ID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error unsetting workspace as favorite",
			Detail:  err.Error(),
		})
		return
	}
	aReq.New = workspace.WorkspaceTable()
	aReq.New.Favorite = false

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Update workspace automatic updates by ID
// @ID update-workspace-automatic-updates-by-id
// @Security CoderSessionToken
// @Accept json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceAutomaticUpdatesRequest true "Automatic updates request"
// @Success 204
// @Router /api/v2/workspaces/{workspace}/autoupdates [put]
func (api *API) putWorkspaceAutoupdates(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx               = r.Context()
		workspace         = httpmw.WorkspaceParam(r)
		auditor           = api.Auditor.Load()
		aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
			Audit:          *auditor,
			Log:            api.Logger,
			Request:        r,
			Action:         database.AuditActionWrite,
			OrganizationID: workspace.OrganizationID,
		})
	)
	defer commitAudit()
	aReq.Old = workspace.WorkspaceTable()

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

	if !database.AutomaticUpdates(req.AutomaticUpdates).Valid() {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid request",
			Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: "must be always or never"}},
		})
		return
	}

	err := api.Database.UpdateWorkspaceAutomaticUpdates(ctx, database.UpdateWorkspaceAutomaticUpdatesParams{
		ID:               workspace.ID,
		AutomaticUpdates: database.AutomaticUpdates(req.AutomaticUpdates),
	})
	if httpapi.Is404Error(err) {
		httpapi.ResourceNotFound(rw)
		return
	}
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error updating workspace automatic updates setting",
			Detail:  err.Error(),
		})
		return
	}

	newWorkspace := workspace
	newWorkspace.AutomaticUpdates = database.AutomaticUpdates(req.AutomaticUpdates)
	aReq.New = newWorkspace.WorkspaceTable()

	rw.WriteHeader(http.StatusNoContent)
}

// @Summary Resolve workspace autostart by id.
// @ID resolve-workspace-autostart-by-id
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 200 {object} codersdk.ResolveAutostartResponse
// @Router /api/v2/workspaces/{workspace}/resolve-autostart [get]
func (api *API) resolveAutostart(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx       = r.Context()
		workspace = httpmw.WorkspaceParam(r)
	)

	template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return
	}

	templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
	useActiveVersion := templateAccessControl.RequireActiveVersion || workspace.AutomaticUpdates == database.AutomaticUpdatesAlways
	if !useActiveVersion {
		httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{})
		return
	}

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

	if build.TemplateVersionID == template.ActiveVersionID {
		httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{})
		return
	}

	version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching template version.",
			Detail:  err.Error(),
		})
		return
	}

	dbVersionParams, err := api.Database.GetTemplateVersionParameters(ctx, version.ID)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching template version parameters.",
			Detail:  err.Error(),
		})
		return
	}

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

	versionParams, err := db2sdk.TemplateVersionParameters(dbVersionParams)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error converting template version parameters.",
			Detail:  err.Error(),
		})
		return
	}

	resolver := codersdk.ParameterResolver{
		Rich: db2sdk.WorkspaceBuildParameters(dbBuildParams),
	}

	var response codersdk.ResolveAutostartResponse
	for _, param := range versionParams {
		_, err := resolver.ValidateResolve(param, nil)
		// There's a parameter mismatch if we get an error back from the
		// resolver.
		response.ParameterMismatch = err != nil
		if response.ParameterMismatch {
			break
		}
	}
	httpapi.Write(ctx, rw, http.StatusOK, response)
}

// @Summary Watch workspace by ID
// @ID watch-workspace-by-id
// @Security CoderSessionToken
// @Produce text/event-stream
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 200 {object} codersdk.Response
// @Router /api/v2/workspaces/{workspace}/watch [get]
// @Deprecated Use /workspaces/{workspace}/watch-ws instead
func (api *API) watchWorkspaceSSE(rw http.ResponseWriter, r *http.Request) {
	api.watchWorkspace(rw, r, httpapi.ServerSentEventSender)
}

// @Summary Watch workspace by ID via WebSockets
// @ID watch-workspace-by-id-via-websockets
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 200 {object} codersdk.ServerSentEvent
// @Router /api/v2/workspaces/{workspace}/watch-ws [get]
func (api *API) watchWorkspaceWS(rw http.ResponseWriter, r *http.Request) {
	api.watchWorkspace(rw, r, httpapi.OneWayWebSocketEventSender(api.Logger, api.wsWatcher))
}

func (api *API) watchWorkspace(
	rw http.ResponseWriter,
	r *http.Request,
	connect httpapi.EventSender,
) {
	ctx := r.Context()
	workspace := httpmw.WorkspaceParam(r)
	apiKey := httpmw.APIKey(r)

	sendEvent, senderClosed, err := connect(rw, r)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error setting up server-sent events.",
			Detail:  err.Error(),
		})
		return
	}
	// Prevent handler from returning until the sender is closed.
	defer func() {
		<-senderClosed
	}()

	sendUpdate := func(_ context.Context, _ []byte) {
		workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
		if err != nil {
			_ = sendEvent(codersdk.ServerSentEvent{
				Type: codersdk.ServerSentEventTypeError,
				Data: codersdk.Response{
					Message: "Internal error fetching workspace.",
					Detail:  err.Error(),
				},
			})
			return
		}

		data, err := api.singleWorkspaceData(ctx, workspace, wsrelated.All())
		if err != nil {
			if errors.Is(err, errWorkspaceTemplateUnauthorized) {
				_ = sendEvent(codersdk.ServerSentEvent{
					Type: codersdk.ServerSentEventTypeError,
					Data: codersdk.Response{
						Message: "Forbidden reading template of selected workspace.",
					},
				})
				return
			}
			_ = sendEvent(codersdk.ServerSentEvent{
				Type: codersdk.ServerSentEventTypeError,
				Data: codersdk.Response{
					Message: "Internal error fetching workspace data.",
					Detail:  err.Error(),
				},
			})
			return
		}

		appStatus := codersdk.WorkspaceAppStatus{}
		if len(data.appStatuses) > 0 {
			appStatus = data.appStatuses[0]
		}
		w, err := convertWorkspace(
			ctx,
			api.Logger,
			apiKey.UserID,
			workspace,
			data.builds[0],
			data.templates[0],
			api.AllowWorkspaceRenames,
			appStatus,
		)
		if err != nil {
			_ = sendEvent(codersdk.ServerSentEvent{
				Type: codersdk.ServerSentEventTypeError,
				Data: codersdk.Response{
					Message: "Internal error converting workspace.",
					Detail:  err.Error(),
				},
			})
		}
		_ = sendEvent(codersdk.ServerSentEvent{
			Type: codersdk.ServerSentEventTypeData,
			Data: w,
		})
	}

	cancelWorkspaceSubscribe, err := api.Pubsub.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID),
		wspubsub.HandleWorkspaceEvent(
			func(ctx context.Context, payload wspubsub.WorkspaceEvent, err error) {
				if err != nil {
					return
				}
				if payload.WorkspaceID != workspace.ID {
					return
				}
				sendUpdate(ctx, nil)
			}))
	if err != nil {
		_ = sendEvent(codersdk.ServerSentEvent{
			Type: codersdk.ServerSentEventTypeError,
			Data: codersdk.Response{
				Message: "Internal error subscribing to workspace events.",
				Detail:  err.Error(),
			},
		})
		return
	}
	defer cancelWorkspaceSubscribe()

	// This is required to show whether the workspace is up-to-date.
	cancelTemplateSubscribe, err := api.Pubsub.Subscribe(watchTemplateChannel(workspace.TemplateID), sendUpdate)
	if err != nil {
		_ = sendEvent(codersdk.ServerSentEvent{
			Type: codersdk.ServerSentEventTypeError,
			Data: codersdk.Response{
				Message: "Internal error subscribing to template events.",
				Detail:  err.Error(),
			},
		})
		return
	}
	defer cancelTemplateSubscribe()

	// An initial ping signals to the request that the server is now ready
	// and the client can begin servicing a channel with data.
	_ = sendEvent(codersdk.ServerSentEvent{
		Type: codersdk.ServerSentEventTypePing,
	})
	// Send updated workspace info after connection is established. This avoids
	// missing updates if the client connects after an update.
	sendUpdate(ctx, nil)

	for {
		select {
		case <-ctx.Done():
			return
		case <-senderClosed:
			return
		}
	}
}

// @Summary Watch all workspace builds
// @ID watch-all-workspace-builds
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Success 101
// @Router /api/experimental/watch-all-workspacebuilds [get]
// @x-apidocgen {"skip": true}
func (api *API) watchAllWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	// This endpoint streams build events for every workspace in the deployment
	// with no per-item filtering, so require deployment-wide read on workspaces.
	// Without this, any authenticated user could enumerate all workspace names
	// and observe build activity across organizations they do not belong to.
	if !api.Authorize(r, policy.ActionRead, rbac.ResourceWorkspace.All()) {
		httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
			Message: "You are not authorized to watch all workspace builds.",
			Detail: "This requires permission to read all workspaces, which is granted by the " +
				"Owner or Template Admin role. Please contact an administrator about your " +
				"permissions if you feel this is an error.",
		})
		return
	}

	// Buffer enough updates to avoid blocking the pubsub callback while we're
	// accepting the WebSocket connection. Accepting the connection signals to
	// the client that the server is subscribed and ready to forward events.
	updates := make(chan codersdk.WorkspaceBuildUpdate, 256)

	cancelSubscribe, err := api.Pubsub.SubscribeWithErr(wspubsub.AllWorkspaceEventChannel,
		wspubsub.HandleWorkspaceBuildUpdate(
			func(_ context.Context, update codersdk.WorkspaceBuildUpdate, err error) {
				if err != nil {
					api.Logger.Warn(ctx, "workspace build update subscription error", slog.Error(err))
					return
				}
				select {
				case updates <- update:
				default:
					api.Logger.Warn(ctx, "workspace build update dropped, client too slow")
				}
			}))
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error subscribing to workspace build events.",
			Detail:  err.Error(),
		})
		return
	}
	defer cancelSubscribe()

	conn, err := websocket.Accept(rw, r, nil)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Failed to accept WebSocket.",
			Detail:  err.Error(),
		})
		return
	}
	defer conn.Close(websocket.StatusNormalClosure, "done")

	// CloseRead starts a goroutine to read and discard messages from the client,
	// including Pong messages sent in response to our Ping heartbeats.
	_ = conn.CloseRead(context.Background())

	ctx, cancel := context.WithCancel(ctx)
	ctx = api.wsWatcher.Watch(ctx, api.Logger, conn)
	defer cancel()

	enc := wsjson.NewEncoder[codersdk.WorkspaceBuildUpdate](conn, websocket.MessageText)
	for {
		select {
		case <-ctx.Done():
			return
		case update, ok := <-updates:
			if !ok {
				return
			}
			if err := enc.Encode(update); err != nil {
				return
			}
		}
	}
}

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

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

	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)
}

// @Summary Get workspace ACLs
// @ID get-workspace-acls
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 200 {object} codersdk.WorkspaceACL
// @Router /api/v2/workspaces/{workspace}/acl [get]
func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx       = r.Context()
		workspace = httpmw.WorkspaceParam(r)
	)

	// Fetch the ACL data.
	workspaceACL, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID)
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return
	}

	// Callers are authorized to read this workspace, not necessarily the
	// users and groups on its ACL. We deliberately use the System context to
	// look up that data, but only return minimal identity information that is
	// safe to expose to anyone who can read the ACL: MinimalUser for ACL users
	// (no email or other PII) and group identity plus a member count for ACL
	// groups (no member roster). This mirrors the chat ACL and template
	// available-ACL endpoints.

	// Fetch all of the users and their organization memberships
	userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users))
	for userID := range workspaceACL.Users {
		id, err := uuid.Parse(userID)
		if err != nil {
			api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
			continue
		}
		userIDs = append(userIDs, id)
	}
	// ACL users are returned as MinimalUser, which contains no PII, so it is
	// safe to fetch them under the System context.
	// nolint:gocritic
	dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
	if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
		httpapi.InternalServerError(rw, err)
		return
	}

	// Convert the db types to the codersdk.WorkspaceUser type
	users := make([]codersdk.WorkspaceUser, 0, len(dbUsers))
	for _, it := range dbUsers {
		users = append(users, codersdk.WorkspaceUser{
			MinimalUser: db2sdk.MinimalUser(it),
			Role:        convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions),
		})
	}

	// Fetch all of the groups
	groupIDs := make([]uuid.UUID, 0, len(workspaceACL.Groups))
	for groupID := range workspaceACL.Groups {
		id, err := uuid.Parse(groupID)
		if err != nil {
			api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
			continue
		}
		groupIDs = append(groupIDs, id)
	}

	// `GetGroups` returns all groups if `GroupIds` is empty so we check the length
	// before making the DB call.
	dbGroups := make([]database.GetGroupsRow, 0)
	if len(groupIDs) > 0 {
		// Group identity must be visible to anyone who can read the ACL so
		// that owners and shared users can see and manage entries.
		// nolint:gocritic
		dbGroups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
		if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
			httpapi.InternalServerError(rw, err)
			return
		}
	}

	// Fetch member counts for all groups in a single query to avoid an N+1
	// lookup. We intentionally do not populate the per-group member rosters:
	// callers authorized to read the ACL are not necessarily authorized to
	// read group membership, and the roster includes member PII. Only the
	// total member count is returned (see Group.TotalMemberCount).
	// nolint:gocritic
	countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{
		GroupIds:      groupIDs,
		IncludeSystem: false,
	})
	if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
		httpapi.InternalServerError(rw, err)
		return
	}
	countByGroup := make(map[uuid.UUID]int64, len(countRows))
	for _, row := range countRows {
		countByGroup[row.GroupID] = row.MemberCount
	}

	groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups))
	for _, it := range dbGroups {
		groups = append(groups, codersdk.WorkspaceGroup{
			Group: db2sdk.Group(it, nil, int(countByGroup[it.Group.ID])),
			Role:  convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions),
		})
	}

	httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{
		Users:  users,
		Groups: groups,
	})
}

// @Summary Update workspace ACL
// @ID update-workspace-acl
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceACL true "Update workspace ACL request"
// @Success 204
// @Router /api/v2/workspaces/{workspace}/acl [patch]
func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx               = r.Context()
		workspace         = httpmw.WorkspaceParam(r)
		auditor           = api.Auditor.Load()
		aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
			Audit:          *auditor,
			Log:            api.Logger,
			Request:        r,
			Action:         database.AuditActionWrite,
			OrganizationID: workspace.OrganizationID,
		})
	)
	defer commitAudit()
	aReq.Old = workspace.WorkspaceTable()

	if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) {
		return
	}

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

	apiKey := httpmw.APIKey(r)
	if _, ok := req.UserRoles[apiKey.UserID.String()]; ok {
		// Block changing your own sharing role unless you can share any
		// workspace in the organization. This keeps a user whose only access is
		// this share from destructively demoting themselves, while letting org
		// and deployment admins manage their own access.
		if !api.Authorize(r, policy.ActionShare, rbac.ResourceWorkspace.InOrg(workspace.OrganizationID)) {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "You cannot change your own workspace sharing role.",
			})
			return
		}
	}

	validErrs := acl.Validate(ctx, api.Database, WorkspaceACLUpdateValidator(req))
	if len(validErrs) > 0 {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid request to update workspace ACL",
			Validations: validErrs,
		})
		return
	}

	err := api.Database.InTx(func(tx database.Store) error {
		var err error
		workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
		if err != nil {
			return xerrors.Errorf("get template by ID: %w", err)
		}

		for id, role := range req.UserRoles {
			if role == codersdk.WorkspaceRoleDeleted {
				delete(workspace.UserACL, id)
				continue
			}
			workspace.UserACL[id] = database.WorkspaceACLEntry{
				Permissions: db2sdk.WorkspaceRoleActions(role),
			}
		}

		for id, role := range req.GroupRoles {
			if role == codersdk.WorkspaceRoleDeleted {
				delete(workspace.GroupACL, id)
				continue
			}
			workspace.GroupACL[id] = database.WorkspaceACLEntry{
				Permissions: db2sdk.WorkspaceRoleActions(role),
			}
		}

		err = tx.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
			ID:       workspace.ID,
			UserACL:  workspace.UserACL,
			GroupACL: workspace.GroupACL,
		})
		if err != nil {
			return xerrors.Errorf("update workspace ACL by ID: %w", err)
		}
		workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
		if err != nil {
			return xerrors.Errorf("get updated workspace by ID: %w", err)
		}
		return nil
	}, nil)
	if err != nil {
		if dbauthz.IsNotAuthorizedError(err) {
			httpapi.Forbidden(rw)
		} else {
			httpapi.InternalServerError(rw, err)
		}
		return
	}

	aReq.New = workspace.WorkspaceTable()

	rw.WriteHeader(http.StatusNoContent)
}

type workspaceData struct {
	templates   []database.Template
	builds      []codersdk.WorkspaceBuild
	appStatuses []codersdk.WorkspaceAppStatus
	// deploymentAllowsRenames is the deprecated deployment-wide rename flag.
	deploymentAllowsRenames bool
}

// @Summary Completely clears the workspace's user and group ACLs.
// @ID completely-clears-the-workspaces-user-and-group-acls
// @Security CoderSessionToken
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 204
// @Router /api/v2/workspaces/{workspace}/acl [delete]
func (api *API) deleteWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
	var (
		ctx                 = r.Context()
		workspace           = httpmw.WorkspaceParam(r)
		auditor             = api.Auditor.Load()
		aReq, commitAuditor = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
			Audit:          *auditor,
			Log:            api.Logger,
			Request:        r,
			Action:         database.AuditActionWrite,
			OrganizationID: workspace.OrganizationID,
		})
	)

	defer commitAuditor()
	aReq.Old = workspace.WorkspaceTable()

	if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) {
		return
	}

	err := api.Database.InTx(func(tx database.Store) error {
		err := tx.DeleteWorkspaceACLByID(ctx, workspace.ID)
		if err != nil {
			return xerrors.Errorf("delete workspace by ID: %w", err)
		}

		workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
		if err != nil {
			return xerrors.Errorf("get updated workspace by ID: %w", err)
		}

		return nil
	}, nil)
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return
	}

	aReq.New = workspace.WorkspaceTable()

	httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}

// allowWorkspaceSharing enforces the workspace-sharing gate for an
// organization. It writes an HTTP error response and returns false if
// sharing is disabled or the org lookup fails; otherwise it returns
// true.
func (api *API) allowWorkspaceSharing(ctx context.Context, rw http.ResponseWriter, organizationID uuid.UUID) bool {
	//nolint:gocritic // Use system context so this check doesn’t
	// depend on the caller having organization:read.
	org, err := api.Database.GetOrganizationByID(dbauthz.AsSystemRestricted(ctx), organizationID)
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return false
	}
	if org.ShareableWorkspaceOwners == database.ShareableWorkspaceOwnersNone {
		httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
			Message: "Workspace sharing is disabled for this organization.",
		})
		return false
	}
	return true
}

// workspacesData only returns the data the caller can access. If the caller
// does not have the correct perms to read a given template, the template will
// not be returned.
// So the caller must check the templates & users exist before using them.
func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspace, cfg wsrelated.Config) (workspaceData, error) {
	workspaceIDs := make([]uuid.UUID, 0, len(workspaces))
	templateIDs := make([]uuid.UUID, 0, len(workspaces))
	for _, workspace := range workspaces {
		workspaceIDs = append(workspaceIDs, workspace.ID)
		templateIDs = append(templateIDs, workspace.TemplateID)
	}

	var (
		templates   []database.Template
		builds      []database.WorkspaceBuild
		appStatuses []database.WorkspaceAppStatus
		eg          errgroup.Group
	)
	if cfg.Template {
		eg.Go(func() (err error) {
			templates, err = api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
				IDs: templateIDs,
			})
			if err != nil && !errors.Is(err, sql.ErrNoRows) {
				return xerrors.Errorf("get templates: %w", err)
			}
			return nil
		})
	}
	if cfg.LatestBuild != nil {
		eg.Go(func() (err error) {
			// This query must be run as system restricted to be efficient.
			// nolint:gocritic
			builds, err = api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs)
			if err != nil && !errors.Is(err, sql.ErrNoRows) {
				return xerrors.Errorf("get workspace builds: %w", err)
			}
			return nil
		})
	}
	if cfg.LatestBuild.AppStatuses() {
		eg.Go(func() (err error) {
			// This query must be run as system restricted to be efficient.
			// nolint:gocritic
			appStatuses, err = api.Database.GetLatestWorkspaceAppStatusesByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs)
			if err != nil && !errors.Is(err, sql.ErrNoRows) {
				return xerrors.Errorf("get workspace app statuses: %w", err)
			}
			return nil
		})
	}
	err := eg.Wait()
	if err != nil {
		return workspaceData{}, err
	}

	var data workspaceBuildsData
	var latestBuildCfg wsrelated.LatestBuild
	if cfg.LatestBuild != nil {
		latestBuildCfg = *cfg.LatestBuild
		data, err = api.workspaceBuildsData(ctx, builds, latestBuildCfg)
		if err != nil {
			return workspaceData{}, xerrors.Errorf("get workspace builds data: %w", err)
		}
	}

	apiBuilds, err := api.convertWorkspaceBuilds(
		latestBuildCfg,
		builds,
		workspaces,
		data.jobs,
		data.resources,
		data.metadata,
		data.agents,
		data.apps,
		data.appStatuses,
		data.scripts,
		data.logSources,
		data.templateVersions,
		data.provisionerDaemons,
	)
	if err != nil {
		return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err)
	}

	return workspaceData{
		templates:               templates,
		appStatuses:             db2sdk.WorkspaceAppStatuses(appStatuses),
		builds:                  apiBuilds,
		deploymentAllowsRenames: api.AllowWorkspaceRenames,
	}, nil
}

// errWorkspaceTemplateUnauthorized indicates the actor requested a workspace's
// template but is not authorized to read it. workspaceData omits templates the
// actor cannot read, so for a single-workspace fetch an absent-but-requested
// template means the read was denied.
var errWorkspaceTemplateUnauthorized = xerrors.New("not authorized to read workspace template")

// errWorkspaceBuildNotFound indicates the actor requested a workspace's latest
// build but none exists. A workspace should always have a build, so an
// absent-but-requested build means the workspace was not found.
var errWorkspaceBuildNotFound = xerrors.New("workspace build not found")

// singleWorkspaceData loads related data for one workspace. Unlike the batch
// workspaceData, which omits templates the actor cannot read so list endpoints
// can skip them, it returns errWorkspaceTemplateUnauthorized when the template
// was requested but is absent, and errWorkspaceBuildNotFound when the latest
// build was requested but is absent. Callers map those errors to the response
// their endpoint requires (403, 404, or an SSE error).
func (api *API) singleWorkspaceData(ctx context.Context, workspace database.Workspace, cfg wsrelated.Config) (workspaceData, error) {
	data, err := api.workspaceData(ctx, []database.Workspace{workspace}, cfg)
	if err != nil {
		return workspaceData{}, err
	}
	if cfg.Template && !slices.ContainsFunc(data.templates, func(t database.Template) bool {
		return t.ID == workspace.TemplateID
	}) {
		return workspaceData{}, errWorkspaceTemplateUnauthorized
	}
	if cfg.LatestBuild != nil && len(data.builds) == 0 {
		return workspaceData{}, errWorkspaceBuildNotFound
	}
	return data, nil
}

// attachAgentMetadata maps the agent metadata the workspaces query
// aggregated per workspace onto the agents in the converted response.
// Each aggregated datum carries its workspace_agent_id. An unparsable
// aggregate degrades to missing metadata for that workspace rather
// than failing the page: the expansion is best-effort decoration on
// top of the list.
func attachAgentMetadata(ctx context.Context, logger slog.Logger, workspaces []codersdk.Workspace, rows []database.GetWorkspacesRow) {
	byAgent := map[uuid.UUID][]database.WorkspaceAgentMetadatum{}
	for _, row := range rows {
		if len(row.AgentMetadata) == 0 {
			continue
		}
		var metadata database.AgentMetadataAggregate
		err := metadata.Scan(row.AgentMetadata)
		if err != nil {
			logger.Warn(ctx, "scan agent metadata, omitting it for the workspace",
				slog.F("workspace_id", row.ID),
				slog.Error(err),
			)
			continue
		}
		for _, datum := range metadata {
			byAgent[datum.WorkspaceAgentID] = append(byAgent[datum.WorkspaceAgentID], datum)
		}
	}
	for wi := range workspaces {
		resources := workspaces[wi].LatestBuild.Resources
		for ri := range resources {
			for ai := range resources[ri].Agents {
				agent := &resources[ri].Agents[ai]
				if metadata, ok := byAgent[agent.ID]; ok {
					agent.Metadata = convertWorkspaceAgentMetadata(metadata)
				}
			}
		}
	}
}

func convertWorkspaces(
	ctx context.Context,
	logger slog.Logger,
	requesterID uuid.UUID,
	workspaces []database.Workspace,
	data workspaceData,
) ([]codersdk.Workspace, error) {
	buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{}
	for _, workspaceBuild := range data.builds {
		buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild
	}
	templateByID := map[uuid.UUID]database.Template{}
	for _, template := range data.templates {
		templateByID[template.ID] = template
	}
	appStatusesByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceAppStatus{}
	for _, appStatus := range data.appStatuses {
		appStatusesByWorkspaceID[appStatus.WorkspaceID] = appStatus
	}

	apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces))
	for _, workspace := range workspaces {
		// If any data is missing from the workspace, just skip returning
		// this workspace. This is not ideal, but the user cannot read
		// all the workspace's data, so do not show them.
		// Ideally we could just return some sort of "unknown" for the missing
		// fields?
		build, exists := buildByWorkspaceID[workspace.ID]
		if !exists {
			continue
		}
		template, exists := templateByID[workspace.TemplateID]
		if !exists {
			continue
		}
		appStatus := appStatusesByWorkspaceID[workspace.ID]

		w, err := convertWorkspace(
			ctx,
			logger,
			requesterID,
			workspace,
			build,
			template,
			data.deploymentAllowsRenames,
			appStatus,
		)
		if err != nil {
			return nil, xerrors.Errorf("convert workspace: %w", err)
		}

		apiWorkspaces = append(apiWorkspaces, w)
	}
	return apiWorkspaces, nil
}

func convertWorkspace(
	ctx context.Context,
	logger slog.Logger,
	requesterID uuid.UUID,
	workspace database.Workspace,
	workspaceBuild codersdk.WorkspaceBuild,
	template database.Template,
	deploymentAllowsRenames bool,
	latestAppStatus codersdk.WorkspaceAppStatus,
) (codersdk.Workspace, error) {
	if requesterID == uuid.Nil {
		return codersdk.Workspace{}, xerrors.Errorf("developer error: requesterID cannot be uuid.Nil!")
	}
	var autostartSchedule *string
	if workspace.AutostartSchedule.Valid {
		autostartSchedule = &workspace.AutostartSchedule.String
	}

	var dormantAt *time.Time
	if workspace.DormantAt.Valid {
		dormantAt = &workspace.DormantAt.Time
	}

	var deletingAt *time.Time
	if workspace.DeletingAt.Valid {
		deletingAt = &workspace.DeletingAt.Time
	}

	var nextStartAt *time.Time
	if workspace.NextStartAt.Valid {
		nextStartAt = &workspace.NextStartAt.Time
	}

	failingAgents := []uuid.UUID{}
	for _, resource := range workspaceBuild.Resources {
		for _, agent := range resource.Agents {
			// Sub-agents (e.g., devcontainer agents) are excluded from the
			// workspace health calculation. Their health is managed by
			// their parent agent, and temporary disconnections during
			// devcontainer rebuilds should not affect workspace health.
			if agent.ParentID.Valid {
				continue
			}
			if !agent.Health.Healthy {
				failingAgents = append(failingAgents, agent.ID)
			}
		}
	}

	ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl)
	// If the template doesn't allow a workspace-configured value, then report the
	// template value instead.
	if !template.AllowUserAutostop {
		ttlMillis = convertWorkspaceTTLMillis(sql.NullInt64{Valid: true, Int64: template.DefaultTTL})
	}

	// Only show favorite status if you own the workspace.
	requesterFavorite := workspace.OwnerID == requesterID && workspace.Favorite

	appStatus := &latestAppStatus
	if latestAppStatus.ID == uuid.Nil {
		appStatus = nil
	}

	return codersdk.Workspace{
		ID:                                   workspace.ID,
		CreatedAt:                            workspace.CreatedAt,
		UpdatedAt:                            workspace.UpdatedAt,
		OwnerID:                              workspace.OwnerID,
		OwnerName:                            workspace.OwnerUsername,
		OwnerAvatarURL:                       workspace.OwnerAvatarUrl,
		OrganizationID:                       workspace.OrganizationID,
		OrganizationName:                     workspace.OrganizationName,
		TemplateID:                           workspace.TemplateID,
		LatestBuild:                          workspaceBuild,
		LatestAppStatus:                      appStatus,
		TemplateName:                         workspace.TemplateName,
		TemplateIcon:                         workspace.TemplateIcon,
		TemplateDisplayName:                  workspace.TemplateDisplayName,
		TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
		TemplateActiveVersionID:              template.ActiveVersionID,
		TemplateRequireActiveVersion:         template.RequireActiveVersion,
		TemplateUseClassicParameterFlow:      template.UseClassicParameterFlow,
		Outdated:                             workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
		Name:                                 workspace.Name,
		AutostartSchedule:                    autostartSchedule,
		TTLMillis:                            ttlMillis,
		LastUsedAt:                           workspace.LastUsedAt,
		DeletingAt:                           deletingAt,
		DormantAt:                            dormantAt,
		Health: codersdk.WorkspaceHealth{
			Healthy:       len(failingAgents) == 0,
			FailingAgents: failingAgents,
		},
		AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates),
		AllowRenames:     deploymentAllowsRenames || template.AllowWorkspaceRenames,
		Favorite:         requesterFavorite,
		NextStartAt:      nextStartAt,
		IsPrebuild:       workspace.IsPrebuild(),
		SharedWith:       sharedWorkspaceActors(ctx, logger, workspace),
	}, nil
}

func sharedWorkspaceActors(
	ctx context.Context,
	logger slog.Logger,
	workspace database.Workspace,
) []codersdk.SharedWorkspaceActor {
	out := make([]codersdk.SharedWorkspaceActor, 0, len(workspace.UserACL)+len(workspace.GroupACL))

	// Users
	for id, aclEntry := range workspace.UserACL {
		userID, err := uuid.Parse(id)
		if err != nil {
			logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
			continue
		}

		out = append(out, codersdk.SharedWorkspaceActor{
			ID:        userID,
			ActorType: codersdk.SharedWorkspaceActorTypeUser,
			Roles:     []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)},
			Name:      workspace.UserACLDisplayInfo[id].Name,
			AvatarURL: workspace.UserACLDisplayInfo[id].AvatarURL,
		})
	}

	// Groups
	for id, aclEntry := range workspace.GroupACL {
		groupID, err := uuid.Parse(id)
		if err != nil {
			logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
			continue
		}

		out = append(out, codersdk.SharedWorkspaceActor{
			ID:        groupID,
			ActorType: codersdk.SharedWorkspaceActorTypeGroup,
			Roles:     []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)},
			Name:      workspace.GroupACLDisplayInfo[id].Name,
			AvatarURL: workspace.GroupACLDisplayInfo[id].AvatarURL,
		})
	}

	return out
}

func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 {
	if !i.Valid {
		return nil
	}

	millis := time.Duration(i.Int64).Milliseconds()
	return &millis
}

func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql.NullInt64, error) {
	if ptr.NilOrZero(millis) {
		if templateDefault == 0 {
			return sql.NullInt64{}, nil
		}

		return sql.NullInt64{
			Int64: int64(templateDefault),
			Valid: true,
		}, nil
	}

	// Check the bounds before converting to a time.Duration: out of range
	// values wrap around and can land inside the valid range. The maximum
	// check compares whole minutes to match the truncation below.
	if *millis < 0 {
		return sql.NullInt64{}, errTTLMin
	}
	if *millis/time.Minute.Milliseconds() > int64(ttlMaximum/time.Minute) {
		return sql.NullInt64{}, errTTLMax
	}

	dur := time.Duration(*millis) * time.Millisecond
	truncated := dur.Truncate(time.Minute)
	if truncated < ttlMinimum {
		return sql.NullInt64{}, errTTLMin
	}

	if truncated > ttlMaximum {
		return sql.NullInt64{}, errTTLMax
	}

	return sql.NullInt64{
		Valid: true,
		Int64: int64(truncated),
	}, nil
}

func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database.AutomaticUpdates, error) {
	if updates == "" {
		return database.AutomaticUpdatesNever, nil
	}
	dbAU := database.AutomaticUpdates(updates)
	if !dbAU.Valid() {
		return "", xerrors.New("Automatic updates must be always or never")
	}
	return dbAU, nil
}

func validWorkspaceDeadline(now, startedAt, newDeadline time.Time) error {
	soon := now.Add(29 * time.Minute)
	if newDeadline.Before(soon) {
		return errDeadlineTooSoon
	}

	// No idea how this could happen.
	if newDeadline.Before(startedAt) {
		return errDeadlineBeforeStart
	}

	return nil
}

func validWorkspaceSchedule(s *string) (sql.NullString, error) {
	if ptr.NilOrEmpty(s) {
		return sql.NullString{}, nil
	}

	_, err := cron.Weekly(*s)
	if err != nil {
		return sql.NullString{}, err
	}

	return sql.NullString{
		Valid:  true,
		String: *s,
	}, nil
}

func (api *API) publishWorkspaceUpdate(ctx context.Context, ownerID uuid.UUID, event wspubsub.WorkspaceEvent) {
	err := wspubsub.PublishWorkspaceEvent(ctx, api.Pubsub, ownerID, event)
	if err != nil {
		api.Logger.Warn(ctx, "failed to publish workspace update",
			slog.F("workspace_id", event.WorkspaceID), slog.Error(err))
	}
}

func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAgentID uuid.UUID, m agentsdk.LogsNotifyMessage) {
	b, err := json.Marshal(m)
	if err != nil {
		api.Logger.Warn(ctx, "failed to marshal logs notify message", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err))
	}
	err = api.Pubsub.Publish(agentsdk.LogsNotifyChannel(workspaceAgentID), b)
	if err != nil {
		api.Logger.Warn(ctx, "failed to publish workspace agent logs update", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err))
	}
}

type WorkspaceACLUpdateValidator codersdk.UpdateWorkspaceACL

var (
	workspaceACLUpdateUsersFieldName  = "user_roles"
	workspaceACLUpdateGroupsFieldName = "group_roles"
)

// WorkspaceACLUpdateValidator implements acl.UpdateValidator[codersdk.WorkspaceRole]
var _ acl.UpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{}

func (w WorkspaceACLUpdateValidator) Users() (map[string]codersdk.WorkspaceRole, string) {
	return w.UserRoles, workspaceACLUpdateUsersFieldName
}

func (w WorkspaceACLUpdateValidator) Groups() (map[string]codersdk.WorkspaceRole, string) {
	return w.GroupRoles, workspaceACLUpdateGroupsFieldName
}

func (WorkspaceACLUpdateValidator) ValidateRole(role codersdk.WorkspaceRole) error {
	actions := db2sdk.WorkspaceRoleActions(role)
	if len(actions) == 0 && role != codersdk.WorkspaceRoleDeleted {
		return xerrors.Errorf("role %q is not a valid workspace role", role)
	}

	return nil
}

func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole {
	switch {
	case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin)):
		return codersdk.WorkspaceRoleAdmin
	case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse)):
		return codersdk.WorkspaceRoleUse
	}

	return codersdk.WorkspaceRoleDeleted
}

// @Summary Get users available for workspace creation
// @ID get-users-available-for-workspace-creation
// @Security CoderSessionToken
// @Produce json
// @Tags Workspaces
// @Param organization path string true "Organization ID" format(uuid)
// @Param user path string true "User ID, name, or me"
// @Param q query string false "Search query"
// @Param limit query int false "Limit results"
// @Param offset query int false "Offset for pagination"
// @Success 200 {array} codersdk.MinimalUser
// @Router /api/v2/organizations/{organization}/members/{user}/workspaces/available-users [get]
func (api *API) workspaceAvailableUsers(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	organization := httpmw.OrganizationParam(r)

	// This endpoint requires the user to be able to create workspaces for other
	// users in this organization. We check if they can create a workspace with
	// a wildcard owner.
	if !api.Authorize(r, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(policy.WildcardSymbol)) {
		httpapi.Forbidden(rw)
		return
	}

	// Use system context to list all users. The authorization check above
	// ensures only users who can create workspaces for others can access this.
	//nolint:gocritic // System context needed to list users for workspace owner selection.
	users, _, ok := api.GetUsers(rw, r.WithContext(dbauthz.AsSystemRestricted(ctx)))
	if !ok {
		return
	}

	minimalUsers := make([]codersdk.MinimalUser, 0, len(users))
	for _, user := range users {
		minimalUsers = append(minimalUsers, codersdk.MinimalUser{
			ID:        user.ID,
			Username:  user.Username,
			Name:      user.Name,
			AvatarURL: user.AvatarURL,
		})
	}

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